snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
Some checks failed
Java CI with Maven / Java 21 (push) Failing after 4m41s
Java CI with Maven / Java 17 (push) Failing after 4m42s

This commit is contained in:
upstream-mirror
2026-09-15 16:13:53 +05:00
commit fcbc274d01
275 changed files with 41230 additions and 0 deletions

View File

@@ -0,0 +1,398 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge;
import org.jetbrains.annotations.*;
import org.jitsi.jicofo.*;
import org.jitsi.jicofo.xmpp.*;
import org.jitsi.utils.logging2.*;
import org.jitsi.xmpp.extensions.health.*;
import org.jivesoftware.smack.*;
import org.jivesoftware.smack.packet.*;
import java.util.*;
import java.util.concurrent.*;
import static org.jitsi.jicofo.bridge.BridgeConfig.config;
import static org.jitsi.jicofo.xmpp.UtilKt.sendIqAndGetResponse;
/**
* The class is responsible for doing health checks of currently known
* jitsi-videobridge instances.
*
* @author Pawel Domas
*/
public class JvbDoctor
implements BridgeSelector.EventHandler
{
/**
* The logger.
*/
private static final Logger logger = new LoggerImpl(JvbDoctor.class.getName());
/**
* Tells how often we send health checks to the bridge in ms.
*/
private final long healthCheckInterval = config.getHealthChecksInterval().toMillis();
/**
* 2nd chance delay which tells how long we will wait to retry the health
* check after 1st attempt has timed out.
*/
private final long secondChanceDelay = config.getHealthChecksRetryDelay().toMillis();
/**
* Health check tasks map.
*/
private final Map<Bridge, PeriodicHealthCheckTask> tasks = new ConcurrentHashMap<>();
private final HealthCheckListener listener;
@NotNull
private final XmppProvider xmppProvider;
/**
* Creates new instance of <tt>JvbDoctor</tt>.
*/
public JvbDoctor(HealthCheckListener listener, @NotNull XmppProvider xmppProvider)
{
this.xmppProvider = xmppProvider;
this.listener = listener;
}
private AbstractXMPPConnection getConnection()
{
return xmppProvider.getXmppConnection();
}
synchronized public void shutdown()
{
// Remove scheduled tasks
ArrayList<Bridge> bridges = new ArrayList<>(tasks.keySet());
for (Bridge bridge : bridges)
{
bridgeRemoved(bridge);
}
}
@Override
public void bridgeFailedHealthCheck(@NotNull Bridge bridge)
{
// JvbDoctor is the source of these events, no need for additional handling.
}
@Override
public void bridgeRemoved(@NotNull Bridge bridge)
{
PeriodicHealthCheckTask healthTask = tasks.remove(bridge);
if (healthTask == null)
{
logger.warn("Trying to remove a bridge that does not exist anymore: " + bridge);
return;
}
logger.info("Stopping health-check task for: " + bridge);
healthTask.cancel();
}
@Override
public void bridgeAdded(@NotNull Bridge bridge)
{
if (tasks.containsKey(bridge))
{
logger.warn("Trying to add already existing bridge: " + bridge);
return;
}
Runnable task = config.getUsePresenceForHealth()
? new HealthCheckPresenceTask(bridge)
: new HealthCheckTask(bridge);
PeriodicHealthCheckTask periodicTask
= new PeriodicHealthCheckTask(task, healthCheckInterval);
tasks.put(bridge, periodicTask);
logger.info("Scheduled health-check task for: " + bridge);
}
@Override
public void bridgeIsShuttingDown(@NotNull Bridge bridge)
{
}
private static class PeriodicHealthCheckTask
{
private Runnable innerTask;
private final ScheduledFuture<?> future;
private Future<?> innerFuture;
private final Object lock = new Object();
private PeriodicHealthCheckTask(Runnable task, long healthCheckInterval)
{
innerTask = task;
future = TaskPools.getScheduledPool().scheduleWithFixedDelay(
() -> innerFuture = TaskPools.getIoPool().submit(runInner),
healthCheckInterval,
healthCheckInterval,
TimeUnit.MILLISECONDS);
}
private final Runnable runInner = () -> {
synchronized (lock)
{
innerTask.run();
}
};
private void cancel()
{
future.cancel(true);
if (innerFuture != null)
{
innerFuture.cancel(true);
}
}
}
private class HealthCheckTask extends AbstractHealthCheckTask
{
private HealthCheckTask(Bridge bridge)
{
super(bridge);
}
private HealthCheckIQ newHealthCheckIQ(Bridge bridge)
{
HealthCheckIQ healthIq = new HealthCheckIQ();
healthIq.setTo(bridge.getJid());
healthIq.setType(IQ.Type.get);
return healthIq;
}
/**
* Performs a health check.
* @throws org.jivesoftware.smack.SmackException.NotConnectedException when XMPP is not connected,
* the task should terminate.
*/
@Override
protected void doHealthCheck()
throws SmackException.NotConnectedException
{
AbstractXMPPConnection connection = getConnection();
// If XMPP is currently not connected skip the health-check
if (!connection.isConnected())
{
logger.warn("XMPP disconnected - skipping health check for: " + bridge);
return;
}
if (taskInvalid())
{
return;
}
logger.debug("Sending health-check request to: " + bridge);
IQ response = sendIqAndGetResponse(connection, newHealthCheckIQ(bridge));
// On timeout we'll give it one more try
if (response == null && secondChanceDelay > 0)
{
try
{
if (taskInvalid())
return;
logger.warn(bridge + " health-check timed out,"
+ " but will give it another try after: "
+ secondChanceDelay);
Thread.sleep(secondChanceDelay);
if (taskInvalid())
return;
response = sendIqAndGetResponse(connection, newHealthCheckIQ(bridge));
}
catch (InterruptedException e)
{
logger.error(bridge + " second chance delay wait interrupted", e);
}
}
// Sync on start/stop and bridges state
synchronized (JvbDoctor.this)
{
if (taskInvalid())
return;
if (response == null)
{
logger.warn("Health check timed out for: " + bridge);
listener.healthCheckTimedOut(bridge.getJid());
return;
}
IQ.Type responseType = response.getType();
if (IQ.Type.result.equals(responseType))
{
// OK
if (logger.isDebugEnabled())
{
logger.debug("Health check passed on: " + bridge);
}
listener.healthCheckPassed(bridge.getJid());
return;
}
if (IQ.Type.error.equals(responseType))
{
StanzaError error = response.getError();
StanzaError.Condition condition = error.getCondition();
if (StanzaError.Condition.internal_server_error.equals(condition)
|| StanzaError.Condition.service_unavailable.equals(condition))
{
// Health check failure
logger.warn("Health check failed for: " + bridge + ": " + error.toXML());
listener.healthCheckFailed(bridge.getJid());
}
else
{
logger.error(
"Unexpected error returned by the bridge: " + bridge + ", err: " + response.toXML()
);
}
}
}
}
}
private abstract class AbstractHealthCheckTask implements Runnable
{
protected final Bridge bridge;
AbstractHealthCheckTask(Bridge bridge)
{
this.bridge = bridge;
}
protected abstract void doHealthCheck()
throws SmackException.NotConnectedException, InterruptedException;
@Override
public void run()
{
try
{
doHealthCheck();
}
catch (Exception e)
{
// If the task was canceled while running it will throw InterruptedException, ignore it.
if (taskInvalid() && e instanceof InterruptedException)
{
logger.debug("The task has been canceled.");
}
else
{
logger.error("Error when doing health-check on: " + bridge, e);
}
}
}
protected boolean taskInvalid()
{
synchronized (JvbDoctor.this)
{
if (!tasks.containsKey(bridge))
{
logger.info("Health check task canceled for: " + bridge);
return true;
}
return false;
}
}
}
private class HealthCheckPresenceTask extends AbstractHealthCheckTask
{
private HealthCheckPresenceTask(Bridge bridge)
{
super(bridge);
}
/**
* Performs a health check.
* @throws org.jivesoftware.smack.SmackException.NotConnectedException when XMPP is not connected,
* the task should terminate.
*/
@Override
protected void doHealthCheck()
{
AbstractXMPPConnection connection = getConnection();
// If XMPP is currently not connected skip the health-check
if (!connection.isConnected())
{
logger.warn("XMPP disconnected - skipping health check for: " + bridge);
return;
}
if (taskInvalid())
{
return;
}
logger.debug("Checking presence for health for: " + bridge);
boolean healthy = bridge.isHealthy();
boolean timeout = bridge.getTimeSinceLastPresence().compareTo(config.getPresenceHealthTimeout()) > 0;
// Sync on start/stop and bridges state
synchronized (JvbDoctor.this)
{
if (taskInvalid())
return;
if (timeout)
{
logger.warn("Health check timed out for: " + bridge);
listener.healthCheckTimedOut(bridge.getJid());
}
else if (!healthy)
{
logger.warn("JVB reported unhealthy" + bridge);
listener.healthCheckFailed(bridge.getJid());
}
else // healthy
{
if (logger.isDebugEnabled())
{
logger.debug("Health check passed on: " + bridge);
}
// TODO: we should be able to do this directly when we receive presence with healthy=true, but
// I don't want to modify the flow right now.
listener.healthCheckPassed(bridge.getJid());
}
}
}
}
}

View File

@@ -0,0 +1,106 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import com.typesafe.config.ConfigObject
import org.jitsi.config.JitsiConfig
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import org.jitsi.utils.TemplatedUrl
import org.jitsi.utils.logging2.createLogger
import java.time.Duration
class TranscriptionConfig private constructor() {
val logger = createLogger()
private val urlTemplate: String? by optionalconfig {
"jicofo.transcription.url-template".from(JitsiConfig.newConfig).transformedBy {
if (!it.contains("{{${MEETING_ID_TEMPLATE}}}")) {
logger.warn("Transcriber URL template does not contain $MEETING_ID_TEMPLATE")
}
it
}
}
private val httpHeadersProp: Map<String, String>? by optionalconfig {
"jicofo.transcription.http-headers".from(JitsiConfig.newConfig)
.convertFrom<ConfigObject> { cfg ->
cfg.entries.associate { entry ->
entry.key to entry.value.unwrapped().toString()
}
}
}
val httpHeaders: Map<String, String>
get() = httpHeadersProp ?: emptyMap()
val pingEnabled: Boolean by config {
"jicofo.transcription.ping.enabled".from(JitsiConfig.newConfig)
}
val pingInterval: Duration by config {
"jicofo.transcription.ping.interval".from(JitsiConfig.newConfig)
}
val pingTimeout: Duration by config {
"jicofo.transcription.ping.timeout".from(JitsiConfig.newConfig)
}
fun getUrl(meetingId: String): TemplatedUrl? = urlTemplate?.let {
TemplatedUrl(it, requiredKeys = setOf(REGION_TEMPLATE)).apply {
set(MEETING_ID_TEMPLATE, meetingId)
}
}
companion object {
@JvmField
val config = TranscriptionConfig()
const val MEETING_ID_TEMPLATE = "MEETING_ID"
const val REGION_TEMPLATE = "REGION"
/**
* Process transcription metadata to extract custom headers and URL params.
* Merges custom headers with base headers, with custom headers taking precedence.
*
* @param transcription The transcription metadata from room metadata
* @param baseHeaders The base headers from configuration
* @return A Pair of (merged headers, url params), where either can be null
*/
@JvmStatic
fun processTranscriptionMetadata(
transcription: org.jitsi.jicofo.xmpp.RoomMetadata.Metadata.Transcription?,
baseHeaders: Map<String, String>
): Pair<Map<String, String>?, Map<String, String>?> {
if (transcription == null) {
return Pair(null, null)
}
val customHeaders = transcription.httpHeaders
val mergedHeaders = if (customHeaders != null) {
baseHeaders.toMutableMap().apply {
putAll(customHeaders)
}
} else {
null
}
return Pair(mergedHeaders, transcription.urlParams)
}
}
}

View File

@@ -0,0 +1,127 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import com.typesafe.config.ConfigObject
import org.jitsi.config.JitsiConfig
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import org.jitsi.utils.TemplatedUrl
import org.jitsi.utils.logging2.createLogger
import java.time.Duration
/** Configuration for live audio translation (the websocket endpoint a bridge connects to). */
class TranslationConfig private constructor() {
val logger = createLogger()
private val urlTemplate: String? by optionalconfig {
"jicofo.translation.url-template".from(JitsiConfig.newConfig).transformedBy {
if (!it.contains("{{$MEETING_ID_TEMPLATE}}")) {
logger.warn("Translator URL template does not contain $MEETING_ID_TEMPLATE")
}
it
}
}
private val httpHeadersProp: Map<String, String>? by optionalconfig {
"jicofo.translation.http-headers".from(JitsiConfig.newConfig)
.convertFrom<ConfigObject> { cfg ->
cfg.entries.associate { entry ->
entry.key to entry.value.unwrapped().toString()
}
}
}
/** Static HTTP headers added to the translator connect (e.g. Cloudflare Access service-token credentials). */
val httpHeaders: Map<String, String>
get() = httpHeadersProp ?: emptyMap()
/** Whether live translation is configured (an URL template is set). */
val enabled: Boolean
get() = urlTemplate != null
/** How translation connects are placed on bridges. */
val mode: Mode by config {
"jicofo.translation.mode".from(JitsiConfig.newConfig).convertFrom<String> {
Mode.valueOf(it.uppercase().replace('-', '_'))
}
}
/**
* The maximum number of target languages handled by a single translation connect. Only applies in [Mode.PER_SOURCE]
* (a sender requesting more is split across multiple connects on its bridge); ignored in [Mode.SINGLE_BRIDGE].
*/
val maxLanguagesPerConnect: Int by config {
"jicofo.translation.max-languages-per-connect".from(JitsiConfig.newConfig)
}
/** Whether the bridge should send keepalive pings on the translator connect (to avoid idle-timeout). */
val pingEnabled: Boolean by config {
"jicofo.translation.ping.enabled".from(JitsiConfig.newConfig)
}
val pingInterval: Duration by config {
"jicofo.translation.ping.interval".from(JitsiConfig.newConfig)
}
val pingTimeout: Duration by config {
"jicofo.translation.ping.timeout".from(JitsiConfig.newConfig)
}
fun getUrl(meetingId: String): TemplatedUrl? = urlTemplate?.let {
TemplatedUrl(it, requiredKeys = setOf(REGION_TEMPLATE)).apply {
set(MEETING_ID_TEMPLATE, meetingId)
}
}
/** How translation connects are placed on bridges. */
enum class Mode {
/** Each sender's audio is translated on its own (local) bridge, split by [maxLanguagesPerConnect]. */
PER_SOURCE,
/** A single connect on one selected bridge handles all sources and languages. */
SINGLE_BRIDGE
}
companion object {
@JvmField
val config = TranslationConfig()
const val MEETING_ID_TEMPLATE = "MEETING_ID"
const val REGION_TEMPLATE = "REGION"
/**
* Merge per-room translation headers (from room metadata) over the static config headers, with the
* per-room values taking precedence. Mirrors [TranscriptionConfig.processTranscriptionMetadata].
*
* Returns null when there are no per-room headers, so callers fall back to the static config headers
* (i.e. `merged ?: TranslationConfig.config.httpHeaders`).
*
* @param translation the translation metadata from room metadata (e.g. a per-customer usage token)
* @param baseHeaders the static headers from configuration
*/
@JvmStatic
fun processTranslationMetadata(
translation: org.jitsi.jicofo.xmpp.RoomMetadata.Metadata.Translation?,
baseHeaders: Map<String, String>
): Map<String, String>? {
val customHeaders = translation?.httpHeaders ?: return null
return baseHeaders.toMutableMap().apply { putAll(customHeaders) }
}
}
}

View File

@@ -0,0 +1,384 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.LoggerImpl
import org.jitsi.utils.stats.RateTracker
import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension
import org.jxmpp.jid.Jid
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max
import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config as config
/**
* Represents a jitsi-videobridge instance, reachable at a certain JID, which
* can be used by jicofo for hosting conferences. Contains the state related
* to the jitsi-videobridge instance, such as numbers of channels and streams,
* the region in which the instance resides, etc.
*
* TODO fix comparator (should not be reflexive unless the objects are the same?)
* @author Pawel Domas
* @author Boris Grozev
*/
@SuppressFBWarnings("EQ_COMPARETO_USE_OBJECT_EQUALS")
class Bridge @JvmOverloads internal constructor(
/**
* The XMPP address of the bridge.
*/
val jid: Jid,
private val clock: Clock = Clock.systemUTC()
) : Comparable<Bridge> {
/** Keep track of the recently added endpoints. */
private val newEndpointsRate = RateTracker(
config.participantRampupInterval,
Duration.ofMillis(100),
clock
)
private val endpointRestartRequestRate = RateTracker(
config.iceFailureDetection.interval,
Duration.ofSeconds(1),
clock
)
/** Number of endpoints currently allocated on this bridge by this jicofo instance. */
val endpoints = AtomicInteger(0)
/** The last report stress level */
var lastReportedStressLevel = 0.0
private set
/** Holds bridge version (if known - not all bridge version are capable of reporting it). */
private var version: String? = null
/** Whether the last received presence indicated the bridge is healthy. */
var isHealthy = true
private set
/** Holds bridge release ID, or null if not known. */
private var releaseId: String? = null
/**
* Stores the `operational` status of the bridge, which is
* `true` if the bridge has been successfully used by the focus to
* allocate channels. It is reset to `false` when the focus fails
* to allocate channels, but it gets another chance when all currently
* working bridges go down and might eventually get elevated back to
* `true`.
*/
@Volatile
var isOperational = true
get() =
// To filter out intermittent failures, do not return operational
// until past the reset threshold since the last failure.
if (failureInstant != null &&
Duration.between(failureInstant, clock.instant()).compareTo(config.failureResetThreshold) < 0
) {
false
} else {
field
}
set(isOperational) {
field = isOperational
if (!isOperational) {
// Remember when the bridge has last failed
failureInstant = clock.instant()
}
}
/** Start out with the configured value, update if the bridge reports a value. */
private var averageParticipantStress = config.averageParticipantStress
/** Stores a boolean that indicates whether the bridge is in graceful shutdown mode. */
var isInGracefulShutdown = false // we assume it is not shutting down
/** Whether the bridge is in SHUTTING_DOWN mode. */
var isShuttingDown = false
private set
/**
* Stores a boolean that indicates whether the bridge is in drain mode.
*/
var isDraining = true // Default to true to prevent unwanted selection before reading actual state
private set
/**
* The time when this instance has failed.
*
* Use `null` to represent "never" because calculating the duration from [Instant.MIN] is slow.
*/
private var failureInstant: Instant? = null
/** @return the region of this [Bridge]. */
var region: String? = null
private set
/** @return the relay ID advertised by the bridge, or `null` if none was advertised. */
var relayId: String? = null
private set
/**
* If this [Bridge] has been removed from the list of bridges. Once removed, the metrics specific to this instance
* are cleared and no longer emitted. If the bridge re-connects, a new [Bridge] instance will be created.
*/
val removed = AtomicBoolean(false)
/**
* The last instant at which we detected, based on restart requests from endpoints, that this bridge is failing ICE
*/
private var lastIceFailed = Instant.MIN
private val failingIce: Boolean
get() = Duration.between(lastIceFailed, clock.instant()) < config.iceFailureDetection.timeout
private val logger: Logger = LoggerImpl(Bridge::class.java.name)
init {
logger.addContext("jid", jid.toString())
}
private var lastPresenceReceived = Instant.MIN
val timeSinceLastPresence: Duration
get() = Duration.between(lastPresenceReceived, clock.instant())
/**
* Notifies this instance that a new [ColibriStatsExtension] was
* received for this instance.
* @param stats the [ColibriStatsExtension] instance which was
* received.
*/
fun setStats(stats: ColibriStatsExtension?) {
if (stats == null) {
return
}
lastPresenceReceived = clock.instant()
val stressLevel = stats.getDouble("stress_level")
if (stressLevel != null) {
lastReportedStressLevel = stressLevel
}
val averageParticipantStress = stats.getDouble("average_participant_stress")
if (averageParticipantStress != null) {
this.averageParticipantStress = averageParticipantStress
}
if (java.lang.Boolean.parseBoolean(
stats.getValueAsString(
ColibriStatsExtension.SHUTDOWN_IN_PROGRESS
)
)
) {
isInGracefulShutdown = true
}
if (java.lang.Boolean.parseBoolean(stats.getValueAsString("shutting_down"))) {
isShuttingDown = true
}
val drainStr = stats.getValueAsString(ColibriStatsExtension.DRAIN)
if (drainStr != null) {
isDraining = java.lang.Boolean.parseBoolean(drainStr)
}
val newVersion = stats.getValueAsString(ColibriStatsExtension.VERSION)
if (newVersion != null) {
version = newVersion
}
val newReleaseId = stats.getValueAsString(ColibriStatsExtension.RELEASE)
if (newReleaseId != null) {
releaseId = newReleaseId
}
val region = stats.getValueAsString(ColibriStatsExtension.REGION)
if (region != null) {
this.region = region
}
val relayId = stats.getValueAsString(ColibriStatsExtension.RELAY_ID)
if (relayId != null) {
this.relayId = relayId
}
val healthy = stats.getValueAsString("healthy")
if (healthy != null) {
isHealthy = java.lang.Boolean.parseBoolean(healthy)
} else if (config.usePresenceForHealth) {
logger.warn(
"Presence-based health checks are enabled, but presence did not include health status. Health " +
"checks for this bridge are effectively disabled."
)
}
}
/**
* Returns a negative number if this instance is more able to serve conferences than o. For details see
* [.compare].
*
* @param other the other bridge instance
*
* @return a negative number if this instance is more able to serve conferences than o
*/
override fun compareTo(other: Bridge): Int = compare(this, other)
/** Notifies this [Bridge] that it was used for a new endpoint. */
fun endpointAdded() {
newEndpointsRate.update(1)
endpoints.incrementAndGet()
if (!removed.get()) {
BridgeMetrics.endpoints.set(endpoints.get().toLong(), listOf(jid.resourceOrEmpty.toString()))
}
}
/** Updates the "endpoints moved" metric for this bridge. */
fun endpointsMoved(count: Long) {
BridgeMetrics.endpointsMoved.add(count, listOf(jid.resourceOrEmpty.toString()))
}
fun endpointRemoved() = endpointsRemoved(1)
fun endpointsRemoved(count: Int) {
endpoints.addAndGet(-count)
if (!removed.get()) {
BridgeMetrics.endpoints.set(endpoints.get().toLong(), listOf(jid.resourceOrEmpty.toString()))
}
if (endpoints.get() < 0) {
logger.error("Removed more endpoints than were allocated. Resetting to 0.", Throwable())
endpoints.set(0)
}
}
internal fun markRemoved() {
if (removed.compareAndSet(false, true)) {
BridgeMetrics.restartRequestsMetric.remove(listOf(jid.resourceOrEmpty.toString()))
BridgeMetrics.endpoints.remove(listOf(jid.resourceOrEmpty.toString()))
BridgeMetrics.failingIce.remove(listOf(jid.resourceOrEmpty.toString()))
BridgeMetrics.endpointsMoved.remove(listOf(jid.resourceOrEmpty.toString()))
}
}
internal fun updateMetrics() {
if (!removed.get()) {
BridgeMetrics.failingIce.set(failingIce, listOf(jid.resourceOrEmpty.toString()))
}
}
fun endpointRequestedRestart() {
endpointRestartRequestRate.update(1)
if (!removed.get()) {
BridgeMetrics.restartRequestsMetric.inc(listOf(jid.resourceOrEmpty.toString()))
}
if (config.iceFailureDetection.enabled) {
val restartCount = endpointRestartRequestRate.getAccumulatedCount()
val endpoints = endpoints.get()
if (endpoints >= config.iceFailureDetection.minEndpoints &&
restartCount > endpoints * config.iceFailureDetection.threshold
) {
// Reset the timeout regardless of the previous state, but only log if the state changed.
if (!failingIce) {
logger.info("Detected an ICE failing state.")
}
lastIceFailed = clock.instant()
}
}
}
/** Returns the net number of video channels recently allocated or removed from this bridge. */
private val recentlyAddedEndpointCount: Long
get() = newEndpointsRate.getAccumulatedCount()
/** The version of this bridge (with embedded release ID, if available). */
val fullVersion: String?
get() = if (version != null && releaseId != null) "$version-$releaseId" else version
override fun toString(): String = String.format(
"Bridge[jid=%s, version=%s, relayId=%s, region=%s, correctedStress=%.2f]",
jid.toString(),
fullVersion,
relayId,
region,
correctedStress
)
/**
* Gets the "stress" of the bridge, represented as a double between 0 and 1 (though technically the value
* can exceed 1).
* @return this bridge's stress level
*/
val correctedStress: Double
get() {
// Correct for recently added endpoints.
// While a stress of 1 indicates a bridge is fully loaded, we allow larger values to keep sorting correctly.
val s = lastReportedStressLevel + recentlyAddedEndpointCount.coerceAtLeast(0) * averageParticipantStress
// Correct for failing ICE.
return if (failingIce) max(s, config.stressThreshold + 0.01) else s
}
/** @return true if the stress of the bridge is greater-than-or-equal to the threshold. */
val isOverloaded: Boolean
get() = correctedStress >= config.stressThreshold
val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("corrected-stress", correctedStress)
put("drain", isDraining)
put("endpoints", endpoints.get())
put("endpoint-restart-requests", endpointRestartRequestRate.getAccumulatedCount())
put("failing-ice", failingIce)
put("graceful-shutdown", isInGracefulShutdown)
put("healthy", isHealthy)
put("operational", isOperational)
put("overloaded", isOverloaded)
put("region", region.toString())
put("relay-id", relayId.toString())
put("release", releaseId.toString())
put("shutting-down", isShuttingDown)
put("stress", lastReportedStressLevel)
put("version", version.toString())
}
companion object {
/**
* Returns a negative number if b1 is more able to serve conferences than b2. The computation is based on the
* following three comparisons
*
* operating bridges < non operating bridges
* not in graceful shutdown mode < bridges in graceful shutdown mode
* lower stress < higher stress
*
* @param b1 the 1st bridge instance
* @param b2 the 2nd bridge instance
*
* @return a negative number if b1 is more able to serve conferences than b2
*/
fun compare(b1: Bridge, b2: Bridge): Int {
val myPriority = getPriority(b1)
val otherPriority = getPriority(b2)
return if (myPriority != otherPriority) {
myPriority - otherPriority
} else {
b1.correctedStress.compareTo(b2.correctedStress)
}
}
private fun getPriority(b: Bridge): Int = if (b.isOperational) {
if (b.isInGracefulShutdown) 2 else 1
} else {
3
}
}
}

View File

@@ -0,0 +1,231 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import com.typesafe.config.ConfigList
import com.typesafe.config.ConfigObject
import com.typesafe.config.ConfigValue
import org.jitsi.config.JitsiConfig
import org.jitsi.jicofo.xmpp.XmppConnectionEnum
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.impl.JidCreate
import java.time.Duration
/**
* Config for classes in the org.jicofo.bridge package.
*/
class BridgeConfig private constructor() {
val maxBridgeParticipants: Int by config {
"org.jitsi.jicofo.BridgeSelector.MAX_PARTICIPANTS_PER_BRIDGE".from(JitsiConfig.legacyConfig)
"$BASE.max-bridge-participants".from(JitsiConfig.newConfig)
}
/**
* The maximum number of participants that a single conference may add to a single bridge within
* [maxBridgeParticipantsInterval]. Use -1 to disable.
*/
val maxBridgeParticipantsPerInterval: Int by config {
"$BASE.max-bridge-participants-per-interval".from(JitsiConfig.newConfig)
}
/** The interval over which [maxBridgeParticipantsPerInterval] is enforced. */
val maxBridgeParticipantsInterval: Duration by config {
"$BASE.max-bridge-participants-interval".from(JitsiConfig.newConfig)
}
val averageParticipantStress: Double by config {
"$BASE.average-participant-stress".from(JitsiConfig.newConfig)
}
val stressThreshold: Double by config { "$BASE.stress-threshold".from(JitsiConfig.newConfig) }
val includeBridgeNameInSessionId: Boolean by config {
"$BASE.include-bridge-name-in-session-id".from(JitsiConfig.newConfig)
}
/**
* How long the "failed" state should be sticky for. Once a [Bridge] goes in a non-operational state (via
* [.setIsOperational]) it will be considered non-operational for at least this amount of time.
* See the tests for example behavior.
*/
val failureResetThreshold: Duration by config {
"org.jitsi.focus.BRIDGE_FAILURE_RESET_THRESHOLD".from(JitsiConfig.legacyConfig)
.convertFrom<Long> { Duration.ofMillis(it) }
"$BASE.failure-reset-threshold".from(JitsiConfig.newConfig)
}
val participantRampupInterval: Duration by config {
"$BASE.participant-rampup-interval".from(JitsiConfig.newConfig)
}
val selectionStrategy: BridgeSelectionStrategy by config {
"org.jitsi.jicofo.BridgeSelector.BRIDGE_SELECTION_STRATEGY".from(JitsiConfig.legacyConfig)
.convertFrom<String> { createSelectionStrategy(it) }
"$BASE.selection-strategy".from(JitsiConfig.newConfig)
.convertFrom<String> { createSelectionStrategy(it) }
}
val participantSelectionStrategy: BridgeSelectionStrategy? by config {
"$BASE.participant-selection-strategy".from(JitsiConfig.newConfig)
.convertFrom<String> { createSelectionStrategy(it) }
}
val visitorSelectionStrategy: BridgeSelectionStrategy? by config {
"$BASE.visitor-selection-strategy".from(JitsiConfig.newConfig)
.convertFrom<String> { createSelectionStrategy(it) }
}
val topologyStrategy: TopologySelectionStrategy by config {
"$BASE.topology-strategy".from(JitsiConfig.newConfig)
.convertFrom<String> { createTopologyStrategy(it) }
}
private fun <T> createClassInstance(className: String): T = try {
val clazz = Class.forName("${javaClass.getPackage().name}.$className")
clazz.getConstructor().newInstance() as T
} catch (e: Exception) {
val clazz = Class.forName(className)
clazz.getConstructor().newInstance() as T
}
private fun createSelectionStrategy(className: String): BridgeSelectionStrategy = createClassInstance(className)
private fun createTopologyStrategy(className: String): TopologySelectionStrategy = createClassInstance(className)
val healthChecksEnabled: Boolean by config {
"org.jitsi.jicofo.HEALTH_CHECK_INTERVAL".from(JitsiConfig.legacyConfig)
.convertFrom<Int> { it > 0 }
"$BASE.health-checks.enabled".from(JitsiConfig.newConfig)
}
val healthChecksInterval: Duration by config {
"org.jitsi.jicofo.HEALTH_CHECK_INTERVAL".from(JitsiConfig.legacyConfig)
.convertFrom<Long> { Duration.ofMillis(it) }
"$BASE.health-checks.interval".from(JitsiConfig.newConfig)
}
val healthChecksRetryDelay: Duration by config {
"org.jitsi.jicofo.HEALTH_CHECK_2NDTRY_DELAY".from(JitsiConfig.legacyConfig)
.convertFrom<Long> { Duration.ofMillis(it) }
"$BASE.health-checks.retry-delay".from(JitsiConfig.newConfig)
"$BASE.health-checks.interval".from(JitsiConfig.newConfig)
.transformedBy { Duration.ofMillis(it.toMillis() / 2) }
}
val usePresenceForHealth: Boolean by config {
"$BASE.health-checks.use-presence".from(JitsiConfig.newConfig)
}
val presenceHealthTimeout: Duration by config {
"$BASE.health-checks.presence-timeout".from(JitsiConfig.newConfig)
}
val breweryJid: EntityBareJid? by optionalconfig {
"org.jitsi.jicofo.BRIDGE_MUC".from(JitsiConfig.legacyConfig).convertFrom<String> {
JidCreate.entityBareFrom(it)
}
"$BASE.brewery-jid".from(JitsiConfig.newConfig).convertFrom<String> {
JidCreate.entityBareFrom(it)
}
}
val xmppConnectionName: XmppConnectionEnum by config {
"jicofo.bridge.xmpp-connection-name".from(JitsiConfig.newConfig)
}
val regionGroups: Map<String, Set<String>> by config {
"jicofo.bridge".from(JitsiConfig.newConfig).convertFrom<ConfigObject> {
val regionGroupsConfigList = it["region-groups"] as? ConfigList ?: emptyList<ConfigValue>()
val regionGroups = regionGroupsConfigList.map { regionsConfigList ->
(regionsConfigList as? ConfigList ?: emptyList<ConfigValue>()).map { region ->
region.unwrapped().toString()
}.toSet()
}.toSet()
mutableMapOf<String, Set<String>>().apply {
regionGroups.forEach { regionGroup ->
regionGroup.forEach { region ->
this[region] = regionGroup
}
}
}
}
}
fun getRegionGroup(region: String?): Set<String> =
if (region == null) emptySet() else regionGroups[region] ?: setOf(region)
val iceFailureDetection = IceFailureDetectionConfig()
val loadRedistribution = LoadRedistributionConfig()
companion object {
const val BASE = "jicofo.bridge"
@JvmField
val config = BridgeConfig()
}
}
class IceFailureDetectionConfig internal constructor() {
val enabled: Boolean by config {
"$BASE.enabled".from(JitsiConfig.newConfig)
}
val interval: Duration by config {
"$BASE.interval".from(JitsiConfig.newConfig)
}
val minEndpoints: Int by config {
"$BASE.min-endpoints".from(JitsiConfig.newConfig)
}
val threshold: Double by config {
"$BASE.threshold".from(JitsiConfig.newConfig)
}
val timeout: Duration by config {
"$BASE.timeout".from(JitsiConfig.newConfig)
}
companion object {
const val BASE = "jicofo.bridge.ice-failure-detection"
}
}
class LoadRedistributionConfig internal constructor() {
val enabled: Boolean by config {
"$BASE.enabled".from(JitsiConfig.newConfig)
}
val interval: Duration by config {
"$BASE.interval".from(JitsiConfig.newConfig)
}
val timeout: Duration by config {
"$BASE.timeout".from(JitsiConfig.newConfig)
}
val stressThreshold: Double by config {
"$BASE.stress-threshold".from(JitsiConfig.newConfig)
}
val endpoints: Int by config {
"$BASE.endpoints".from(JitsiConfig.newConfig)
}
override fun toString(): String =
"LoadRedistributionConfig(enabled=$enabled, interval=$interval, timeout=$timeout, " +
"stressThreshold=$stressThreshold, endpoints=$endpoints)"
companion object {
const val BASE = "jicofo.bridge.load-redistribution"
}
}

View File

@@ -0,0 +1,33 @@
package org.jitsi.jicofo.bridge
import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer
class BridgeMetrics {
companion object {
/** Total number of participants that requested a restart for a specific bridge. */
val restartRequestsMetric = metricsContainer.registerCounter(
"bridge_restart_requests_total",
"Total number of requests to restart a bridge",
labelNames = listOf("jvb")
)
val endpoints = metricsContainer.registerLongGauge(
"bridge_endpoints",
"The number of endpoints on a bridge.",
labelNames = listOf("jvb")
)
val failingIce = metricsContainer.registerBooleanMetric(
"bridge_failing_ice",
"Whether a bridge is currently in the failing ICE state.",
labelNames = listOf("jvb")
)
val endpointsMoved = metricsContainer.registerCounter(
"bridge_endpoints_moved",
"Total number of endpoints moved away from a bridge for automatic load redistribution.",
labelNames = listOf("jvb")
)
val rateLimited = metricsContainer.registerCounter(
"bridge_selection_rate_limited",
"Total number of times a bridge was considered overloaded for a conference because the conference had " +
"reached max-bridge-participants-per-interval on it."
)
}
}

View File

@@ -0,0 +1,54 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2018-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.jicofo.xmpp.BaseBrewery
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jitsi.utils.logging2.LoggerImpl
import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
/**
* Detects jitsi-videobridge instances through a MUC.
*
* @author Boris Grozev
*/
class BridgeMucDetector(
xmppProvider: XmppProvider,
/**
* The [BridgeSelector] instance which will be notified when new jitsi-videobridge instances are detected, or when
* they update their status.
*/
private val bridgeSelector: BridgeSelector,
breweryJid: EntityBareJid
) : BaseBrewery<ColibriStatsExtension?>(
xmppProvider,
breweryJid,
ColibriStatsExtension.ELEMENT,
ColibriStatsExtension.NAMESPACE,
LoggerImpl(BridgeMucDetector::class.simpleName).apply { addContext("type", "bridge") }
) {
override fun onInstanceStatusChanged(jid: EntityFullJid, stats: ColibriStatsExtension) {
bridgeSelector.addJvbAddress(jid, stats)
}
override fun notifyInstanceOffline(jid: Jid) = bridgeSelector.removeJvbAddress(jid)
}

View File

@@ -0,0 +1,364 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.createLogger
import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config as config
/**
* Represents an algorithm for bridge selection.
*/
abstract class BridgeSelectionStrategy {
private val logger: Logger = createLogger()
/**
* Selects a bridge to be used for a new participant in a conference.
*
* @param bridges the list of bridges to select from.
* @param conferenceBridges the bridges already in use by the conference
* for which for which a bridge is to be selected.
* @param participantRegion the region of the participant for which
* a bridge is to be selected.
* @return the selected bridge, or `null` if no bridge is
* available.
*/
open fun select(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
allowMultiBridge: Boolean
): Bridge? {
logRateLimitedBridges(conferenceBridges)
return if (conferenceBridges.isEmpty()) {
val bridge = doSelect(bridges, conferenceBridges, participantProperties)
if (bridge != null) {
logger.info(
"Selected initial bridge $bridge with reported stress=${bridge.lastReportedStressLevel} " +
"for participantProperties=$participantProperties"
)
} else {
logger.warn(
"Failed to select initial bridge for participantProperties=$participantProperties"
)
}
bridge
} else {
val existingBridge = conferenceBridges.keys.first()
if (!allowMultiBridge || existingBridge.relayId == null) {
logger.info("Existing bridge does not have a relay, will not consider other bridges.")
return existingBridge
}
val bridge = doSelect(bridges, conferenceBridges, participantProperties)
if (bridge != null) {
logger.info(
"Selected bridge $bridge with stress=${bridge.lastReportedStressLevel} " +
"for participantProperties=$participantProperties"
)
} else {
logger.warn("Failed to select bridge for participantProperties=$participantProperties")
}
bridge
}
}
/**
* Finds the least loaded bridge in the participant's region that is not
* overloaded and that is already handling the conference.
*
* @param bridges the list of operational bridges, ordered by load.
* @param conferenceBridges the set of bridges that are already used in the conference.
* @param desiredRegion the selection strategy's desired region. Depending on the policy this
* may or may not be the same as the participant's region.
*
* @return a bridge that is not loaded and that
* is already in the conference and that is in the participant region, if one
* exists, or null
*/
fun notLoadedAlreadyInConferenceInRegion(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String?
): Bridge? {
val result = bridges
.filterNot { it.isOverloaded(conferenceBridges) }
.intersect(conferenceBridges.keys)
.firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties, desiredRegion)
}
return result
}
fun notLoadedAlreadyInConferenceInRegionGroup(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String?
): Bridge? {
val regionGroup = config.getRegionGroup(desiredRegion)
val result = bridges
.filterNot { it.isOverloaded(conferenceBridges) }
.intersect(conferenceBridges.keys)
.firstOrNull { regionGroup.contains(it.region) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties, desiredRegion)
}
return result
}
private fun logSelection(
bridge: Bridge,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String? = null
) {
val method = Thread.currentThread().stackTrace[2].methodName
logger.debug {
"Bridge selected: method=$method, desiredRegion=$desiredRegion, " +
"participantProperties=$participantProperties, bridge=$bridge, " +
"conference_bridges=${conferenceBridges.keys.joinToString()}"
}
}
/**
* Finds the least loaded bridge in the desired region that is not
* overloaded.
*
* @param bridges the list of operational bridges, ordered by load.
* @param desiredRegion the selection strategy's desired region. Depending on the policy this
* may or may not be the same as the participant's region.
*
* @return a bridge that is not loaded and that is in the participant region, if one exists, or null
*/
fun notLoadedInRegion(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String?
): Bridge? {
val result = bridges
.filterNot { it.isOverloaded(conferenceBridges) }
.firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties, desiredRegion)
}
return result
}
fun notLoadedInRegionGroup(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String?
): Bridge? {
val regionGroup = config.getRegionGroup(desiredRegion)
val result = bridges
.filterNot { it.isOverloaded(conferenceBridges) }
.firstOrNull { regionGroup.contains(it.region) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties, desiredRegion)
}
return result
}
fun notLoaded(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
val result = bridges.firstOrNull { !it.isOverloaded }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties)
}
return result
}
/**
* Finds the least loaded conference bridge in the desired region that
* is already handling the conference.
*
* @param bridges the list of operational bridges, ordered by load.
* @param conferenceBridges the set of bridges that are already used in the conference.
* @param desiredRegion the selection strategy's desired region. Depending on the policy this
* may or may not be the same as the participant's region.
*
* @return the least loaded bridge that is already
* in the conference and that is in the participant region, if it exists, or null
*/
fun leastLoadedAlreadyInConferenceInRegion(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String?
): Bridge? {
val result = bridges
.intersect(conferenceBridges.keys)
.firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties, desiredRegion)
}
return result
}
fun leastLoadedNotMaxedAlreadyInConference(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
val result = bridges
.intersect(conferenceBridges.keys)
.firstOrNull { !it.hasMaxParticipantsInConference(conferenceBridges) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties)
}
return result
}
/**
* Finds the least loaded bridge in a desired region.
*
* @param bridges the list of operational bridges, ordered by load.
* @param desiredRegion the selection strategy's desired region. Depending on the policy this
* may or may not be the same as the participant's region.
*
* @return the least loaded bridge in the participant's region, if it exists, or null.
*/
fun leastLoadedInRegion(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
desiredRegion: String?
): Bridge? {
val result = bridges
.firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) }
if (result != null) {
logSelection(result, conferenceBridges, participantProperties, desiredRegion)
}
return result
}
/**
* Finds the least loaded non overloaded bridge that is already handling the conference.
*
* @param bridges the list of operational bridges, ordered by load.
* @param conferenceBridges the set of bridges that are already used in the
* conference.
*
* @return the least loaded bridge that is already in the conference, if it exists, or null
*/
fun notLoadedAlreadyInConference(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
val result = bridges
.filterNot { it.isOverloaded(conferenceBridges) }
.intersect(conferenceBridges.keys)
.firstOrNull()
if (result != null) {
logSelection(result, conferenceBridges, participantProperties)
}
return result
}
/**
* Finds the least loaded bridge.
*
* @param bridges the list of operational bridges, ordered by load.
*
* @return the least loaded bridge, if it exists, or null.
*/
fun leastLoaded(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
val result = bridges.firstOrNull()
if (result != null) {
logSelection(result, conferenceBridges, participantProperties)
}
return result
}
/**
* Selects a bridge to be used for a new participant in a conference.
*
* @param bridges the list of bridges to select from.
* @param conferenceBridges the list of bridges currently used by the
* conference.
* @param participantRegion the region of the participant for which a
* bridge is to be selected.
* @return the selected bridge, or `null` if no bridge is
* available.
*/
abstract fun doSelect(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge?
/**
* Checks whether a [Bridge] should be considered overloaded for a
* particular conference.
* @param bridge the bridge
* @param conferenceBridges the bridges in the conference
* @return `true` if the bridge should be considered overloaded.
*/
private fun Bridge.isOverloaded(conferenceBridges: Map<Bridge, ConferenceBridgeProperties>): Boolean =
isOverloaded ||
hasMaxParticipantsInConference(conferenceBridges) ||
hasMaxRecentParticipantsInConference(conferenceBridges)
private fun Bridge.hasMaxParticipantsInConference(
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>
): Boolean = config.maxBridgeParticipants > 0 &&
conferenceBridges.containsKey(this) &&
conferenceBridges[this]!!.participantCount >= config.maxBridgeParticipants
/**
* Whether the conference has recently added too many endpoints to this bridge, i.e. it is growing on this bridge
* faster than the bridge's stress reports can keep up with. Note that this is specific to the conference, and does
* not affect the way the bridge is treated for other conferences.
*/
private fun Bridge.hasMaxRecentParticipantsInConference(
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>
): Boolean = config.maxBridgeParticipantsPerInterval > 0 &&
(conferenceBridges[this]?.recentlyAddedParticipantCount ?: 0) >=
config.maxBridgeParticipantsPerInterval
/**
* Log (and count) the bridges on which the conference has hit the [config.maxBridgeParticipantsPerInterval] limit.
* This is only for visibility, the limit itself is enforced in [hasMaxRecentParticipantsInConference].
*/
private fun logRateLimitedBridges(conferenceBridges: Map<Bridge, ConferenceBridgeProperties>) {
if (config.maxBridgeParticipantsPerInterval <= 0) return
val rateLimited = conferenceBridges.filterValues {
it.recentlyAddedParticipantCount >= config.maxBridgeParticipantsPerInterval
}
if (rateLimited.isNotEmpty()) {
BridgeMetrics.rateLimited.inc()
logger.info(
"The conference has recently added too many endpoints to these bridges, they will not be used for " +
"this participant: " +
rateLimited.entries.joinToString {
"${it.key}=${it.value.recentlyAddedParticipantCount}"
}
)
}
}
}

View File

@@ -0,0 +1,302 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.jicofo.OctoConfig
import org.jitsi.jicofo.metrics.JicofoMetricsContainer
import org.jitsi.utils.concurrent.CustomizableThreadFactory
import org.jitsi.utils.event.AsyncEventEmitter
import org.jitsi.utils.logging2.createLogger
import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension
import org.jxmpp.jid.Jid
import java.time.Clock
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* Class exposes methods for selecting best videobridge from all currently
* available.
*
* @author Pawel Domas
* @author Boris Grozev
*/
class BridgeSelector @JvmOverloads constructor(
private val clock: Clock = Clock.systemUTC()
) : HealthCheckListener {
private val logger = createLogger()
/**
* TODO: Refactor to use a common executor.
*/
private val eventEmitterExecutor =
Executors.newSingleThreadExecutor(CustomizableThreadFactory("BridgeSelector-AsyncEventEmitter", false))
private val eventEmitter = AsyncEventEmitter<EventHandler>(eventEmitterExecutor)
fun addHandler(eventHandler: EventHandler) = eventEmitter.addHandler(eventHandler)
fun removeHandler(eventHandler: EventHandler) = eventEmitter.removeHandler(eventHandler)
/** The bridge selection strategy. */
private val bridgeSelectionStrategy = BridgeConfig.config.selectionStrategy.also {
logger.info("Using ${it.javaClass.name}")
}
/** The map of bridge JID to <tt>Bridge</tt>. */
private val bridges: MutableMap<Jid, Bridge> = ConcurrentHashMap()
/** Get the [Bridge] with a specific JID or null */
fun get(jid: Jid) = bridges[jid]
init {
JicofoMetricsContainer.instance.metricsUpdater.addUpdateTask { updateMetrics() }
}
fun hasNonOverloadedBridge(): Boolean = bridges.values.any { !it.isOverloaded }
fun getAll(): List<Bridge> = bridges.values.toList()
val operationalBridgeCount: Int
@Synchronized
get() = bridges.values.count { it.isOperational && !it.isInGracefulShutdown }
/**
* Adds a bridge to this selector, or if a bridge with the given JID
* already exists updates its stats.
*
* @param bridgeJid the JID of videobridge to be added to this selector's
* set of videobridges.
* @param stats the last reported statistics
* @return the [Bridge] instance for the given JID.
*/
@JvmOverloads
@Synchronized
fun addJvbAddress(bridgeJid: Jid, stats: ColibriStatsExtension? = null): Bridge = bridges[bridgeJid]?.let {
val wasShutingDown = it.isShuttingDown
it.setStats(stats)
if (!wasShutingDown && it.isShuttingDown) {
logger.info("${it.jid} entered SHUTTING_DOWN")
eventEmitter.fireEvent { bridgeIsShuttingDown(it) }
}
return it
} ?: Bridge(bridgeJid, clock).also { newBridge ->
if (stats != null) {
newBridge.setStats(stats)
}
logger.info("Added new videobridge: $newBridge")
bridges[bridgeJid] = newBridge
bridgeCount.inc()
eventEmitter.fireEvent { bridgeAdded(newBridge) }
}
/**
* Removes a [Bridge] with a specific JID from the list of videobridge instances.
*
* @param bridgeJid the JID of bridge to remove.
*/
@Synchronized
fun removeJvbAddress(bridgeJid: Jid) {
logger.info("Removing JVB: $bridgeJid")
bridges.remove(bridgeJid)?.let {
if (!it.isInGracefulShutdown && !it.isShuttingDown) {
logger.warn("Lost a bridge: $bridgeJid")
lostBridges.inc()
}
it.markRemoved()
bridgeCount.dec()
eventEmitter.fireEvent { bridgeRemoved(it) }
}
}
override fun healthCheckPassed(bridgeJid: Jid) {
bridges[bridgeJid]?.isOperational = true
}
override fun healthCheckFailed(bridgeJid: Jid) = bridges[bridgeJid]?.let {
// When a bridge returns a non-healthy status, we mark it as non-operational AND we move all conferences
// away from it.
it.isOperational = false
eventEmitter.fireEvent { bridgeFailedHealthCheck(it) }
} ?: Unit
override fun healthCheckTimedOut(bridgeJid: Jid) = bridges[bridgeJid]?.let {
// We are more lenient when a health check times out as opposed to failing with an error. We mark it as
// non-operational to prevent new conferences being allocated there, but do not move existing conferences
// away from it (which is what `notifyBridgeDown` would trigger).
//
// The reason for this is to better handle the case of an intermittent network failure between the bridge
// and jicofo that does not affect the endpoints. In this case a conference will be moved away from the
// bridge if and when a request for that conference fails or times out. This prevents unnecessary moves when
// the bridge eventually recovers (the XMPP/MUC disconnect takes much longer than a health check timing
// out), and prevents a burst of requests due to all conferences being moved together (this is especially
// bad when multiple bridges experience network problems, and conference from one failing bridge are
// attempted to be moved to another failing bridge).
// The other possible case is that the bridge is not responding to jicofo, and is also unavailable to
// endpoints. In this case we rely on endpoints reporting ICE failures to jicofo, which then trigger a move.
it.isOperational = false
} ?: Unit
/**
* Selects a bridge to be used for a specific new [Participant] of
* a specific [JitsiMeetConference].
*
* @return the selected bridge, represented by its [Bridge].
* @param conferenceBridges the bridges in the conference mapped to the number of participants on each.
* @param participantRegion the region of the participant for which a
* bridge is to be selected.
*/
@Synchronized
@JvmOverloads
fun selectBridge(
conferenceBridges: Map<Bridge, ConferenceBridgeProperties> = emptyMap(),
participantProperties: ParticipantProperties = ParticipantProperties(),
/**
* A specific jitsi-videobridge version to use, or null to use any version. If conferenceBridges is non-empty
* the version needs to match the version of the existing bridges.
* */
version: String? = null
): Bridge? {
var v = conferenceBridges.keys.firstOrNull()?.fullVersion
if (v == null) {
v = version
} else if (version != null && version != v) {
logger.warn("An inconsistent version was requested: $version. Conference is using version: $v")
return null
}
// the list of all known videobridges JIDs ordered by load and *operational* status.
val prioritizedBridges = synchronized(this) { ArrayList(bridges.values) }
prioritizedBridges.sort()
var candidateBridges = prioritizedBridges.filter { it.isOperational }.toList()
if (candidateBridges.isEmpty()) {
logger.warn("There are no operational bridges.")
return null
}
candidateBridges = candidateBridges.filter { !it.isShuttingDown }.toList()
if (candidateBridges.isEmpty()) {
logger.warn("All operational bridges are SHUTTING_DOWN")
return null
}
if (v != null && !OctoConfig.config.allowMixedVersions) {
candidateBridges = candidateBridges.filter { it.fullVersion == v }
if (candidateBridges.isEmpty()) {
logger.warn("There are no bridges with the required version: $v")
return null
}
}
// If there are active bridges, prefer those.
val activeBridges = candidateBridges.filter { !it.isDraining }.toList()
if (!activeBridges.isEmpty()) {
candidateBridges = activeBridges
}
// If there are bridges not shutting down, prefer those.
val runningBridges = candidateBridges.filter { !it.isInGracefulShutdown }.toList()
if (!runningBridges.isEmpty()) {
candidateBridges = runningBridges
}
return bridgeSelectionStrategy.select(
candidateBridges,
conferenceBridges,
participantProperties,
OctoConfig.config.enabled
)
}
val stats: ObjectNode
@Synchronized
get() = JsonNodeFactory.instance.objectNode().apply {
// We want to avoid exposing unnecessary hierarchy levels in the stats,
// so we'll merge stats from different "child" objects here.
put("bridge_count", bridgeCount.get())
put("operational_bridge_count", operationalBridgeCountMetric.get())
put("in_shutdown_bridge_count", inShutdownBridgeCountMetric.get())
put("lost_bridges", lostBridges.get())
put("bridge_version_count", bridgeVersionCount.get())
}
val debugState: ObjectNode
@Synchronized
get() = JsonNodeFactory.instance.objectNode().apply {
put("strategy", bridgeSelectionStrategy.javaClass.simpleName)
set<ObjectNode>(
"bridge",
JsonNodeFactory.instance.objectNode().apply {
bridges.values.forEach { set<ObjectNode>(it.jid.toString(), it.debugState) }
}
)
}
fun updateMetrics() {
inShutdownBridgeCountMetric.set(bridges.values.count { it.isInGracefulShutdown }.toLong())
operationalBridgeCountMetric.set(bridges.values.count { it.isOperational }.toLong())
bridgeVersionCount.set(bridges.values.map { it.fullVersion }.toSet().size.toLong())
bridges.values.forEach { it.updateMetrics() }
}
companion object {
@JvmField
val lostBridges = JicofoMetricsContainer.instance.registerCounter(
"bridge_selector_lost_bridges",
"Number of bridges which disconnected unexpectedly."
)
@JvmField
val bridgeCount = JicofoMetricsContainer.instance.registerLongGauge(
"bridge_selector_bridge_count",
"The current number of bridges"
)
val operationalBridgeCountMetric = JicofoMetricsContainer.instance.registerLongGauge(
"bridge_selector_bridge_count_operational",
"The current number of operational bridges"
)
val inShutdownBridgeCountMetric = JicofoMetricsContainer.instance.registerLongGauge(
"bridge_selector_bridge_count_in_shutdown",
"The current number of bridges in graceful shutdown"
)
val bridgeVersionCount = JicofoMetricsContainer.instance.registerLongGauge(
"bridge_selector_bridge_version_count",
"The current number of different bridge versions"
)
}
interface EventHandler {
fun bridgeRemoved(bridge: Bridge)
fun bridgeAdded(bridge: Bridge)
fun bridgeFailedHealthCheck(bridge: Bridge)
fun bridgeIsShuttingDown(bridge: Bridge) {}
}
}
data class ConferenceBridgeProperties(
val participantCount: Int,
val visitor: Boolean = false,
/**
* The number of endpoints that this conference has added to this bridge within
* [BridgeConfig.maxBridgeParticipantsInterval].
*/
val recentlyAddedParticipantCount: Long = 0
)
data class ParticipantProperties(
val region: String? = null,
val visitor: Boolean = false
)

View File

@@ -0,0 +1,326 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import kotlin.collections.HashSet
/**
* A representation of a cascade of bridges.
*/
interface Cascade<N : CascadeNode<N, L>, L : CascadeLink> {
val sessions: MutableMap<String?, N>
fun addLinkBetween(session: N, otherSession: N, meshId: String)
fun removeLinkTo(session: N, otherSession: N)
}
/**
* A representation of a single bridge in a cascade
*/
interface CascadeNode<N : CascadeNode<N, L>, L : CascadeLink> {
val relayId: String?
val relays: MutableMap<String, L>
}
/**
* A representation of a link between bridges in a cascade
*/
interface CascadeLink {
val relayId: String?
val meshId: String?
}
data class CascadeRepair<N : CascadeNode<N, L>, L : CascadeLink>(
val node: N,
val other: N,
val meshId: String
)
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.containsNode(node: N) = sessions[node.relayId] === node
fun <N : CascadeNode<N, L>, L : CascadeLink> N.hasMesh(meshId: String?): Boolean =
relays.values.any { it.meshId == meshId }
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.hasMesh(meshId: String?): Boolean =
sessions.values.any { it.hasMesh(meshId) }
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getMeshNodes(meshId: String?): List<N> =
sessions.values.filter { it.hasMesh(meshId) }
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.addNodeToMesh(
newNode: N,
meshId: String,
existingNode: N? = null
) {
require(!containsNode(newNode)) {
"Cascade $this already contains node $newNode"
}
if (sessions.isEmpty()) {
sessions[newNode.relayId] = newNode
return
} else if (sessions.size == 1) {
val onlyNode = sessions.values.first()
if (existingNode != null) {
require(existingNode == onlyNode) {
"Cascade $this does not contain node $existingNode"
}
}
addLinkBetween(onlyNode, newNode, meshId)
sessions[newNode.relayId] = newNode
return
}
val meshNodes = getMeshNodes(meshId)
if (meshNodes.isEmpty()) {
require(existingNode != null) {
"An existing node must be specified (non-null) when adding a new mesh $meshId to cascade $this"
}
require(containsNode(existingNode)) {
"Cascade $this does not contain node $existingNode"
}
addLinkBetween(existingNode, newNode, meshId)
sessions[newNode.relayId] = newNode
} else {
// ?? Should we check if existingNode, if not null, is a member of meshNodes?
meshNodes.forEach { node -> addLinkBetween(node, newNode, meshId) }
sessions[newNode.relayId] = newNode
}
}
fun <C : Cascade<N, L>, N : CascadeNode<N, L>, L : CascadeLink> C.removeNode(
node: N,
repairFn: (C, Set<Set<N>>) -> Set<CascadeRepair<N, L>>
) {
if (!containsNode(node)) {
return; // Or should this be an exception. i.e. `require`?
}
check(sessions[node.relayId] === node) {
"Bridge entry for ${node.relayId} is not $node"
}
sessions.remove(node.relayId)
node.relays.keys.forEach { key ->
val other = sessions[key]
checkNotNull(other) {
"Cascade does not contain node for $key"
}
val backLink = other.relays[node.relayId]
checkNotNull(backLink) {
"Node $other does not have backlink to $node"
}
check(backLink.relayId == node.relayId) {
"Backlink from $other to $node points to ${backLink.relayId}"
}
other.relays.remove(node.relayId)
removeLinkTo(other, node)
// The reverse-path links do not need to be removed, because the whole node is removed.
}
val meshes = node.relays.values.map { it.meshId }.toSet()
if (meshes.size > 1) {
/* The removed node was a bridge between two or more meshes - we need to repair the cascade. */
val disconnected = node.relays.values.groupBy { it.meshId }.values.map {
it.flatMap { getNodesBehind(node, it.relayId!!) }.toSet()
}.toSet()
val newLinks = repairFn(this, disconnected)
newLinks.forEach { (node, other, mesh) ->
addLinkBetween(node, other, mesh)
}
/* TODO: validate that the newly-added links have left us with a valid cascade? */
}
}
/** Return a set of all nodes "behind" a given node link. */
/* TODO: would this be better as an iterator? */
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getNodesBehind(from: N, toward: N): Set<N> {
val nodes = HashSet<N>()
val link = requireNotNull(from.relays[toward.relayId]) {
"$from does not have a link to $toward"
}
getNodesBehind(link, toward, nodes)
return nodes
}
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getNodesBehind(from: N, towardId: String): Set<N> {
val toward = requireNotNull(sessions[towardId]) {
"$towardId not found in sessions"
}
return getNodesBehind(from, toward)
}
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getNodesBehind(fromMesh: String, toward: N): Set<N> {
val nodes = HashSet<N>()
nodes.add(toward)
toward.relays.values.filter { it.meshId != fromMesh }.forEach {
val next = requireNotNull(sessions[it.relayId])
getNodesBehind(it, next, nodes)
}
return nodes
}
private fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getNodesBehind(
link: L,
toward: N,
nodes: MutableSet<N>
) {
nodes.add(toward)
toward.relays.values.filter { it.meshId != link.meshId }.forEach {
val next = checkNotNull(sessions[it.relayId])
getNodesBehind(it, next, nodes)
}
}
/* Traverse the graph from a node; for each other node, indicate the node from which it was reached. */
fun <C : Cascade<N, L>, N : CascadeNode<N, L>, L : CascadeLink> C.getPathsFrom(node: N, pathFn: (C, N, N?) -> Unit) {
pathFn(this, node, null)
node.relays.values.forEach {
getPathsFrom(it, node, pathFn)
}
}
private fun <C : Cascade<N, L>, N : CascadeNode<N, L>, L : CascadeLink> C.getPathsFrom(
link: L,
from: N,
pathFn: (C, N, N?) -> Unit
) {
val node = checkNotNull(sessions[link.relayId])
pathFn(this, node, from)
node.relays.values.filter { it.meshId != link.meshId }.forEach {
getPathsFrom(it, node, pathFn)
}
}
/** Validate a node, or throw IllegalStateException. */
private fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.validateNode(node: N) {
node.relays.entries.forEach { (key, link) ->
check(key != node.relayId) {
"$node has a link to itself"
}
check(key == link.relayId) {
"$node link indexed by $key links to $link"
}
val other = sessions[key]
checkNotNull(other) {
"$node has link to $key not found in cascade"
}
val backLink = other.relays[node.relayId]
checkNotNull(backLink) {
"$node has link to $other, but $other has no link to $node"
}
check(link.meshId == backLink.meshId) {
"$node links to $other in mesh ${link.meshId}, but backlink has mesh ${backLink.meshId}"
}
}
}
/** Validate a mesh, or throw IllegalStateException. */
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.validateMesh(meshId: String?) {
val meshNodes = getMeshNodes(meshId)
meshNodes.forEach { node ->
meshNodes.forEach { other ->
if (other != node) {
check(node.relays.contains(other.relayId)) {
"$node has no link to $other in mesh $meshId"
}
val link = node.relays[other.relayId]
check(link!!.meshId == meshId) {
"link from $node to $other has meshId ${link.meshId}, not expected $meshId"
}
}
}
}
}
private fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.visitNodeForValidation(
node: N,
parent: N?,
visitedNodes: MutableSet<String?>,
validatedMeshes: MutableSet<String?>
) {
validateNode(node)
visitedNodes.add(node.relayId)
node.relays.values.forEach {
if (!visitedNodes.contains(it.relayId)) {
if (!validatedMeshes.contains(it.meshId)) {
validateMesh(it.meshId)
validatedMeshes.add(it.meshId)
}
val linkedNode = sessions[it.relayId]
checkNotNull(linkedNode) {
"$node has link to node ${it.relayId} not found in cascade"
}
visitNodeForValidation(linkedNode, node, visitedNodes, validatedMeshes)
} else {
check(it.relayId == parent?.relayId || validatedMeshes.contains(it.meshId)) {
"Multiple paths found to ${sessions[it.relayId]}"
}
}
}
}
/** Validate a cascade, or throw IllegalStateException */
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.validate() {
if (sessions.isEmpty()) {
/* Empty cascade is trivially valid */
return
}
val firstNode = sessions.values.first()
val visitedNodes = HashSet<String?>()
val validatedMeshes = HashSet<String?>()
visitNodeForValidation(firstNode, null, visitedNodes, validatedMeshes)
check(visitedNodes.size == sessions.size) {
val unvisitedNodes = sessions.keys.subtract(visitedNodes)
"Nodes ${unvisitedNodes.joinToString()} not reachable from initial node $firstNode"
}
}
/* Get the distance, in hops in the cascade, from one node to some node satisfying a property;
* return [Int.MAX_VALUE] if no path found.
* Note this may not necessarily be the shortest such path, if more than one path is possible to a node satisfying
* the predicate.
*/
fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getDistanceFrom(node: N, pred: (N) -> Boolean): Int {
if (pred(node)) return 0
node.relays.values.forEach {
val distance = getDistanceFrom(it, pred)
if (distance != Int.MAX_VALUE) return distance + 1
}
return Int.MAX_VALUE
}
private fun <N : CascadeNode<N, L>, L : CascadeLink> Cascade<N, L>.getDistanceFrom(link: L, pred: (N) -> Boolean): Int {
val node = checkNotNull(sessions[link.relayId])
if (pred(node)) return 0
node.relays.values.filter { it.meshId != link.meshId }.forEach {
val distance = getDistanceFrom(it, pred)
if (distance != Int.MAX_VALUE) return distance + 1
}
return Int.MAX_VALUE
}

View File

@@ -0,0 +1,26 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jxmpp.jid.Jid
interface HealthCheckListener {
fun healthCheckPassed(bridgeJid: Jid)
fun healthCheckFailed(bridgeJid: Jid)
fun healthCheckTimedOut(bridgeJid: Jid)
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
/**
* Implements a [BridgeSelectionStrategy] which selects bridges in a single
* region, but uses multiple bridges for load balancing.
*/
class IntraRegionBridgeSelectionStrategy : BridgeSelectionStrategy() {
override fun doSelect(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
): Bridge? {
val participantRegion = participantProperties.region
if (bridges.isEmpty()) {
return null
}
if (conferenceBridges.isEmpty()) {
// Try to match the participant region for the initial selection
return notLoadedInRegion(bridges, conferenceBridges, participantProperties, participantRegion)
?: leastLoaded(bridges, conferenceBridges, participantProperties)
}
val conferenceRegion = conferenceBridges.keys.first().region
return notLoadedAlreadyInConferenceInRegion(bridges, conferenceBridges, participantProperties, conferenceRegion)
?: notLoadedInRegion(bridges, conferenceBridges, participantProperties, conferenceRegion)
?: leastLoadedAlreadyInConferenceInRegion(
bridges,
conferenceBridges,
participantProperties,
conferenceRegion
)
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.jicofo.JicofoConfig
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.LoggerImpl
/**
* Implements a [BridgeSelectionStrategy] which attempts to select bridges in the participant's region.
*
* Specifically, it implements the following algorithm:
*
* (Happy case 1): If there is a non-overloaded bridge in the conference
* and in the region: use the least loaded of them.
* (Happy case 1A): If there is a non-overloaded bridge in the conference
* and in the region's group: use the least loaded of them.
* (Happy case 2): If there is a non-overloaded bridge in the region,
* and the conference has no bridges in the region: use the least loaded bridge
* in the region.
* (Happy case 2A): If there is a non-overloaded bridge in the region's group,
* and the conference has no bridges in the region's group: use the least loaded bridge
* in the region's group.
*
* (Split case 1): If there is a non-overloaded bridge in the region, the
* conference has bridges in the region but all are overloaded: Use the least
* loaded of the bridges in the region.
* (Split case 1A): If there is a non-overloaded bridge in the region's group, the
* conference has bridges in the region's group but all are overloaded: Use the least
* loaded of the bridges in the region.
*
* (Overload case 1): If all bridges in the region's group are overloaded, and the
* conference has a bridge in the region's group: use the least loaded conference
* bridge in the region's group.
* (Overload case 2): If all bridges in the region's group are overloaded, and the
* conference has no bridges in the region's group: use the least loaded bridge in
* the region's group.
*
* (No-region-match case 1): If there are no bridges in the region's group, and the
* conference has a non-overloaded bridge: use the least loaded conference bridge.
* (No-region-match case 2): If there are no bridges in the region's group and all
* conference bridges are overloaded: use the least loaded bridge.
*
*/
class RegionBasedBridgeSelectionStrategy : BridgeSelectionStrategy() {
/**
* Map a region to the set of all regions in its group (including itself).
*/
val localRegion = JicofoConfig.config.localRegion()
override fun doSelect(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
if (bridges.isEmpty()) {
return null
}
val participantRegion = participantProperties.region
var region = participantRegion ?: localRegion
if (localRegion != null) {
val regionGroup = BridgeConfig.config.getRegionGroup(region)
if (conferenceBridges.isEmpty() && region != localRegion) {
// Selecting an initial bridge for a participant not in the local region. This is most likely because
// exactly one of the first two participants in the conference is not in the local region, and we're
// selecting for it first. I.e. there is another participant in the local region which will be
// subsequently invited.
if (regionGroup.contains(localRegion)) {
// With the above assumption, there are two participants in the local region group. Therefore,
// they will use the same bridge. Prefer to use a bridge in the local region.
region = localRegion
}
}
// If there are no bridges in the participant region or region group, select from the local region instead.
if (bridges.none { regionGroup.contains(it.region) }) {
region = localRegion
}
}
return notLoadedAlreadyInConferenceInRegion(bridges, conferenceBridges, participantProperties, region)
?: notLoadedAlreadyInConferenceInRegionGroup(bridges, conferenceBridges, participantProperties, region)
?: notLoadedInRegion(bridges, conferenceBridges, participantProperties, region)
?: notLoadedInRegionGroup(bridges, conferenceBridges, participantProperties, region)
?: notLoadedAlreadyInConference(bridges, conferenceBridges, participantProperties)
?: notLoaded(bridges, conferenceBridges, participantProperties)
?: leastLoadedNotMaxedAlreadyInConference(bridges, conferenceBridges, participantProperties)
?: leastLoaded(bridges, conferenceBridges, participantProperties)
}
override fun toString(): String = "${javaClass.simpleName} with region groups: ${BridgeConfig.config.regionGroups}"
override fun select(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
allowMultiBridge: Boolean
): Bridge? {
if (!allowMultiBridge) {
logger.warn(
"Octo is disabled, but the selection strategy is RegionBased. Enable octo in jicofo.conf to " +
"allow use of multiple bridges in a conference."
)
}
return super.select(bridges, conferenceBridges, participantProperties, allowMultiBridge)
}
companion object {
private val logger: Logger = LoggerImpl(RegionBasedBridgeSelectionStrategy::class.java.name)
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.LoggerImpl
/**
* A [BridgeSelectionStrategy] implementation which keeps all
* participants in a conference on the same bridge.
*/
class SingleBridgeSelectionStrategy : BridgeSelectionStrategy() {
/**
* {@inheritDoc}
*
* Always selects the bridge already used by the conference.
*/
override fun doSelect(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
if (conferenceBridges.isEmpty()) {
return leastLoadedInRegion(bridges, emptyMap(), participantProperties, participantProperties.region)
?: leastLoaded(bridges, emptyMap(), participantProperties)
} else if (conferenceBridges.size != 1) {
logger.error("Unexpected number of bridges with SingleBridgeSelectionStrategy: ${conferenceBridges.size}")
return null
}
val bridge = conferenceBridges.keys.first()
if (!bridge.isOperational) {
logger.error("The conference already has a bridge, but it is not operational: bridge=$bridge")
return null
}
return bridge
}
companion object {
/**
* The logger.
*/
private val logger: Logger = LoggerImpl(SingleBridgeSelectionStrategy::class.java.name)
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.jicofo.bridge.colibri.Colibri2CascadeRepair
import org.jitsi.jicofo.bridge.colibri.Colibri2Session
import org.jitsi.jicofo.bridge.colibri.ColibriV2SessionManager
/** Put all bridge nodes into a single mesh, named "0". */
class SingleMeshTopologyStrategy : TopologySelectionStrategy() {
override fun connectNode(cascade: ColibriV2SessionManager, node: Colibri2Session): TopologySelectionResult =
TopologySelectionResult(cascade.sessions.values.firstOrNull(), "0")
override fun repairMesh(
cascade: ColibriV2SessionManager,
disconnected: Set<Set<Colibri2Session>>
): Set<Colibri2CascadeRepair> {
assert(false) {
"Single Mesh policy should never result in disconnected meshes"
}
return setOf()
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.LoggerImpl
/**
* Implements a [BridgeSelectionStrategy] which "splits" a conference to as many bridges as possible (always
* selects a bridge not in the conference or with the fewest endpoints from that conference). For testing purposes only.
*/
class SplitBridgeSelectionStrategy : BridgeSelectionStrategy() {
/**
* {@inheritDoc}
*
* Always selects the bridge already used by the conference.
*/
override fun doSelect(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
// If there's any bridge not yet in this conference, use that; otherwise
// find the bridge with the fewest participants
val bridgeNotYetInConf = bridges.firstOrNull { !conferenceBridges.containsKey(it) }
return bridgeNotYetInConf ?: conferenceBridges.entries
.filter { bridges.contains(it.key) }
.minByOrNull { it.value.participantCount }?.key
}
override fun select(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties,
allowMultiBridge: Boolean
): Bridge? {
if (!allowMultiBridge) {
logger.warn(
"Force-enabling octo for SplitBridgeSelectionStrategy. To suppress this warning, enable octo" +
" in jicofo.conf."
)
}
return super.select(bridges, conferenceBridges, participantProperties, true)
}
companion object {
private val logger: Logger = LoggerImpl(SplitBridgeSelectionStrategy::class.java.name)
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.jicofo.bridge.colibri.Colibri2CascadeRepair
import org.jitsi.jicofo.bridge.colibri.Colibri2Session
import org.jitsi.jicofo.bridge.colibri.ColibriV2SessionManager
/**
* Represents a strategy for selecting bridge topologies.
*/
abstract class TopologySelectionStrategy {
abstract fun connectNode(cascade: ColibriV2SessionManager, node: Colibri2Session): TopologySelectionResult
abstract fun repairMesh(
cascade: ColibriV2SessionManager,
disconnected: Set<Set<Colibri2Session>>
): Set<Colibri2CascadeRepair>
}
data class TopologySelectionResult(
val existingNode: Colibri2Session?,
val meshId: String
)

View File

@@ -0,0 +1,24 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension
fun ColibriStatsExtension.getDouble(name: String): Double? = try {
getValueAsString(name)?.toDouble()
} catch (e: Exception) {
null
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
/**
* A bridge selection strategy that selects distinct sets of bridges for visitors and participants, when possible.
* The selection strategy for each category of participant is specified independently and invoked recursively.
*/
class VisitorSelectionStrategy : BridgeSelectionStrategy() {
private val participantSelectionStrategy = checkNotNull(BridgeConfig.config.participantSelectionStrategy) {
"participant-selection-strategy must be set when VisitorSelectionStrategy is used"
}
private val visitorSelectionStrategy = checkNotNull(BridgeConfig.config.visitorSelectionStrategy) {
"visitor-selection-strategy must be set when VisitorSelectionStrategy is used"
}
override fun doSelect(
bridges: List<Bridge>,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties>,
participantProperties: ParticipantProperties
): Bridge? {
val eligibleBridges = bridges.filter {
val conferenceBridge = conferenceBridges[it]
// The bridge is not in the conference, or it's used for the same type of endpoint
conferenceBridge == null || conferenceBridge.visitor == participantProperties.visitor
}
val conferenceBridgesOfType = conferenceBridges.filter { it.value.visitor == participantProperties.visitor }
val strategy = if (participantProperties.visitor) {
visitorSelectionStrategy
} else {
participantSelectionStrategy
}
return strategy.doSelect(eligibleBridges, conferenceBridgesOfType, participantProperties)
/* If there are no available bridges filtered by participant type, allow mixing. */
?: strategy.doSelect(bridges, conferenceBridges, participantProperties)
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import org.jitsi.jicofo.bridge.colibri.Colibri2CascadeRepair
import org.jitsi.jicofo.bridge.colibri.Colibri2Session
import org.jitsi.jicofo.bridge.colibri.ColibriV2SessionManager
/** Put participant bridges in a core mesh, and visitor bridges in region-based satellite trees. */
class VisitorTopologyStrategy : TopologySelectionStrategy() {
private var meshCounter = 1
private fun nextMesh(): String = meshCounter.toString().also { meshCounter++ }
/* Pick the best node to connect a node to from a set of existing nodes. */
private fun pickConnectionNode(
cascade: ColibriV2SessionManager,
node: Colibri2Session,
existingNodes: Collection<Colibri2Session>
): Colibri2Session {
val nodesWithDistance = existingNodes.associateWith {
cascade.getDistanceFrom(it) { node -> !node.visitor }
}
val sortedNodes = nodesWithDistance.entries.sortedWith(
compareBy({ it.value }, { it.key.bridge.correctedStress })
).map { it.key }
/* TODO: this logic looks a lot like bridge selection. Do we want to try to share logic with that code? */
val nonOverloadedInRegion = sortedNodes.filter {
!it.bridge.isOverloaded && it.bridge.region == node.bridge.region
}
/* Is this the right tradeoff - or do we want to pick overloaded nodes in the region first? */
val nonOverloaded = sortedNodes.filter {
!it.bridge.isOverloaded
}
val inRegion = sortedNodes.filter { it.bridge.region == node.bridge.region }
return nonOverloadedInRegion.firstOrNull()
?: nonOverloaded.firstOrNull()
?: inRegion.firstOrNull()
?: existingNodes.first()
}
override fun connectNode(cascade: ColibriV2SessionManager, node: Colibri2Session): TopologySelectionResult {
val existingNodes = cascade.sessions.values
if (!node.visitor) {
return TopologySelectionResult(existingNodes.firstOrNull(), CORE_MESH)
}
if (existingNodes.isEmpty()) {
/* This is the first bridge, the value doesn't matter. */
/* TODO: if the first bridge is a visitor, do we want to add a core bridge too? */
return TopologySelectionResult(null, nextMesh())
}
val best = pickConnectionNode(cascade, node, existingNodes)
return TopologySelectionResult(best, nextMesh())
}
override fun repairMesh(
cascade: ColibriV2SessionManager,
disconnected: Set<Set<Colibri2Session>>
): Set<Colibri2CascadeRepair> {
// Figure out which part of the disconnected topology contains the core.
val ret = HashSet<Colibri2CascadeRepair>()
val core = disconnected.firstOrNull { set -> set.any { !it.visitor } }
/* We don't have a core. TODO: add one? */
/* Just treat the first disconnected block as though it were the core. */
?: disconnected.first()
val others = disconnected.subtract(setOf(core))
others.forEach {
val connect = it.first() // Should I try to be smarter here?
val best = pickConnectionNode(cascade, connect, core)
ret.add(Colibri2CascadeRepair(connect, best, nextMesh()))
}
return ret
}
companion object {
private const val CORE_MESH = "0"
}
}

View File

@@ -0,0 +1,702 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import io.opentelemetry.api.trace.Span
import io.opentelemetry.context.Context
import org.jitsi.jicofo.OctoConfig
import org.jitsi.jicofo.bridge.Bridge
import org.jitsi.jicofo.bridge.BridgeConfig
import org.jitsi.jicofo.bridge.CascadeLink
import org.jitsi.jicofo.bridge.CascadeNode
import org.jitsi.jicofo.codec.CodecUtil
import org.jitsi.jicofo.codec.Config
import org.jitsi.jicofo.conference.source.ConferenceSourceMap
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.createChildLogger
import org.jitsi.utils.stats.RateTracker
import org.jitsi.xmpp.extensions.TraceParent
import org.jitsi.xmpp.extensions.colibri.WebSocketPacketExtension
import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint
import org.jitsi.xmpp.extensions.colibri2.Colibri2Error
import org.jitsi.xmpp.extensions.colibri2.Colibri2Relay
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
import org.jitsi.xmpp.extensions.colibri2.Endpoints
import org.jitsi.xmpp.extensions.colibri2.InitialLastN
import org.jitsi.xmpp.extensions.colibri2.Media
import org.jitsi.xmpp.extensions.colibri2.Sctp
import org.jitsi.xmpp.extensions.colibri2.Transport
import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension
import org.jitsi.xmpp.extensions.jingle.ExtmapAllowMixedPacketExtension
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
import org.jivesoftware.smack.StanzaCollector
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smackx.muc.MUCRole
import java.time.Duration
import java.util.Collections.singletonList
import java.util.UUID
import kotlin.math.ceil
/** The size of the buckets used to track the rate at which endpoints are added to a session. */
private const val BUCKET_SIZE_MS = 100L
/** Represents a colibri2 session with one specific bridge. */
class Colibri2Session(
val colibriSessionManager: ColibriV2SessionManager,
val bridge: Bridge,
// Whether the session was constructed for the purpose of visitor nodes
val visitor: Boolean,
parentLogger: Logger
) : CascadeNode<Colibri2Session, Colibri2Session.Relay> {
private val logger = createChildLogger(parentLogger).apply {
bridge.jid.resourceOrNull?.toString()?.let { addContext("bridge", it) }
}
private val xmppConnection = colibriSessionManager.xmppConnection
val id = if (BridgeConfig.config.includeBridgeNameInSessionId) {
"${bridge.jid.resourceOrEmpty}-${UUID.randomUUID()}"
} else {
UUID.randomUUID().toString()
}
/**
* Keep track of the endpoints that this conference recently added to this bridge. Note that this is
* per-(conference, bridge), as opposed to [Bridge]'s own tracker which is per-bridge (across all conferences).
*
* We use the number-of-buckets constructor (as opposed to the window-size one) so that an interval which is not a
* multiple of the bucket size is rounded up instead of failing at runtime.
*/
private val newEndpointsRate = RateTracker(
numBuckets = ceil(
BridgeConfig.config.maxBridgeParticipantsInterval.toMillis().toDouble() / BUCKET_SIZE_MS
).toInt().coerceAtLeast(1),
bucketSize = Duration.ofMillis(BUCKET_SIZE_MS),
clock = colibriSessionManager.clock
)
/** The number of endpoints that this conference recently added to this bridge. */
internal val recentlyAddedEndpointCount: Long
get() = newEndpointsRate.getAccumulatedCount()
/**
* Notifies this session that it was used for a new endpoint. Note that endpoints leaving are intentionally not
* subtracted (this models the rate at which endpoints were *added*, matching [Bridge.endpointAdded]).
*/
internal fun endpointAdded() {
newEndpointsRate.update(1)
bridge.endpointAdded()
}
/**
* The colibri2 `<connect>`s currently active on this session, by id (the last set signaled to the bridge).
* Managed via [setInitialConnects] (for the create request) and [setConnects] (delta updates).
*/
private val connects = LinkedHashMap<String, ConnectSpec>()
/**
* Save the relay ID locally since it is possible for the relay ID of the Bridge to change and we don't want it to
* change in the context of a session. We maintain the invariant that whenever a conference has multiple sessions,
* they all have non-null relay IDs.
*/
override val relayId: String? = bridge.relayId
/**
* Whether the colibri2 conference has been created. It is created with the first endpoint allocation request
* ([sendAllocationRequest]).
*/
var created = false
/**
* The sources advertised by the bridge, read from the response of the initial request to create a conference.
*/
internal var feedbackSources: ConferenceSourceMap = ConferenceSourceMap()
/** The set of (octo) relays for the session, mapped by their ID (i.e. the relayId of the remote bridge). */
override val relays = mutableMapOf<String, Relay>()
/** Creates and sends a request to allocate a new endpoint. Returns a [StanzaCollector] for the response. */
internal fun sendAllocationRequest(participant: ParticipantInfo): StanzaCollector {
val request = createRequest(!created)
val endpoint = participant.toEndpoint(create = true, expire = false).apply {
if (participant.audioMuted || participant.videoMuted) {
setForceMute(participant.audioMuted, participant.videoMuted)
}
if (participant.visitor) {
setMucRole(MUCRole.visitor)
}
setTransport(
Transport.getBuilder().apply {
// TODO: we're hard-coding the role here, and it must be consistent with the role signaled to the
// client. Signaling inconsistent roles leads to hard to debug issues (e.g. sporadic ICE/DTLS
// failures with firefox but not chrome).
setIceControlling(true)
if (participant.useSctp) {
setSctp(Sctp.Builder().build())
}
}.build()
)
}
participant.medias.forEach { endpoint.addMedia(it) }
request.addEndpoint(endpoint.build())
logger.trace { "Sending allocation request for ${participant.id}: ${request.build().toXML()}" }
created = true
return xmppConnection.createStanzaCollectorAndSend(request.build())
}
/** Updates the transport info and/or sources for an existing endpoint. */
internal fun updateParticipant(
participant: ParticipantInfo,
/** The transport info to set for the colibri2 endpoint, or null if it is not to be modified. */
transport: IceUdpTransportPacketExtension?,
/** The sources to set for the colibri2 endpoint, or null if the sources are not to be modified. */
sources: EndpointSourceSet?,
initialLastN: InitialLastN?
) {
if (transport == null && sources == null && initialLastN == null) {
logger.info("Nothing to update.")
return
}
val request = createRequest()
val endpoint = Colibri2Endpoint.getBuilder().apply {
setId(participant.id)
setStatsId(participant.statsId)
}
if (transport != null) {
endpoint.setTransport(Transport.getBuilder().setIceUdpExtension(transport).build())
}
if (sources != null) {
endpoint.setSources(sources.toColibriMediaSources(participant.id))
}
initialLastN?.let {
endpoint.setInitialLastN(it)
}
request.addEndpoint(endpoint.build())
sendRequest(request.build(), "updateParticipant")
}
/**
* Ask the bridge to perform an in-place ICE restart for an endpoint: it creates a new ICE agent with fresh
* credentials while the existing one keeps carrying media, and answers with the new transport.
*
* The bridge's new credentials are relayed on to the endpoint (via
* [ColibriV2SessionManager.endpointIceRestarted]), which addresses its connectivity checks to them.
*/
internal fun restartIce(participant: ParticipantInfo) {
val request = createRequest()
request.addEndpoint(
Colibri2Endpoint.getBuilder().apply {
setId(participant.id)
setStatsId(participant.statsId)
setTransport(Transport.getBuilder().setIceRestart(true).build())
}.build()
)
logger.info("Requesting an ICE restart for ${participant.id}")
sendRequest(request.build(), "restartIce") { response ->
val bridgeTransport = response.endpoints.find { it.id == participant.id }?.transport?.iceUdpTransport
if (bridgeTransport == null) {
// The bridge can not restart ICE at all: restarts are disabled or misconfigured, the transport
// is stopped (the endpoint is going away), or it failed to create the new Agent. It says so by
// omitting the transport rather than by returning an error, so that one endpoint's request does
// not fail the whole conference-modify.
logger.warn("No transport in the response to an ICE restart request for ${participant.id}")
colibriSessionManager.endpointIceRestartFailed(participant.id)
} else {
// Either the transport of the new Agent, tagged with its ice-generation, or -- when the bridge
// did not need to restart, because its transport is not established yet -- the transport of the
// Agent it already has, with no generation. See endpointIceRestarted.
colibriSessionManager.endpointIceRestarted(participant.id, bridgeTransport)
}
}
}
internal fun updateForceMute(participants: Set<ParticipantInfo>) {
val request = createRequest()
participants.forEach { participant ->
request.addEndpoint(
Colibri2Endpoint.getBuilder().apply {
setId(participant.id)
setForceMute(participant.audioMuted, participant.videoMuted)
}.build()
)
}
sendRequest(request.build(), "updateForceMute")
}
/** Expire the entire conference. */
internal fun expire() {
relays.clear()
val request = createRequest().setExpire(true)
sendRequest(request.build(), "expire")
}
/** Expire the colibri2 endpoint for a specific participant */
internal fun expire(participantToExpire: ParticipantInfo) = expire(singletonList(participantToExpire))
/** Expire the colibri2 endpoints for a set of participants. */
internal fun expire(participantsToExpire: List<ParticipantInfo>) {
if (participantsToExpire.isEmpty()) {
logger.debug { "No participants to expire." }
return
}
val request = createRequest()
participantsToExpire.forEach { request.addExpire(it.id) }
logger.debug { "Expiring endpoint: ${participantsToExpire.map { it.id }}" }
sendRequest(request.build(), "expire(participantsToExpire)")
}
private fun createRequest(create: Boolean = false) = ConferenceModifyIQ.builder(xmppConnection).apply {
to(bridge.jid)
setMeetingId(colibriSessionManager.meetingId)
Span.fromContextOrNull(Context.current())?.let {
addExtension(
TraceParent(
it.spanContext.traceId,
it.spanContext.spanId,
it.spanContext.traceFlags.asHex()
)
)
}
if (create) {
setCreate(true)
setConferenceName(colibriSessionManager.conferenceName)
setRtcstatsEnabled(colibriSessionManager.rtcStatsEnabled)
// Include the session's connects in the create request so they don't need a separate update IQ.
connects.values.forEach { addConnect(it.toConnect(create = true)) }
}
}
/**
* Pre-populate the set of connects to include in this session's create request, without sending anything. Used
* when a session is created while connects are already desired for it.
*/
fun setInitialConnects(specs: List<ConnectSpec>) {
connects.clear()
specs.forEach { connects[it.id] = it }
}
/**
* Apply [desired] as the full set of connects for this session, signaling only the delta to the bridge:
* connects with a new id are added with create=true, connects no longer present are expired, and connects whose
* parameters changed are re-signaled as an update. Connects whose parameters are unchanged are left alone.
*/
fun setConnects(desired: List<ConnectSpec>) {
val desiredById = desired.associateBy { it.id }
val request = createRequest()
var hasDelta = false
(connects.keys - desiredById.keys).toList().forEach { id ->
logger.info("Expiring connect id=$id")
request.addConnect(connects.getValue(id).toConnect(expire = true))
hasDelta = true
}
desiredById.forEach { (id, spec) ->
val current = connects[id]
when {
current == null -> {
logger.info("Adding connect id=$id")
request.addConnect(spec.toConnect(create = true))
hasDelta = true
}
!current.sameAs(spec) -> {
logger.info("Updating connect id=$id")
request.addConnect(spec.toConnect())
hasDelta = true
}
}
}
connects.clear()
connects.putAll(desiredById)
if (hasDelta) {
sendRequest(request.build(), "setConnects")
}
}
/**
* Create a new colibri2 relay. This sends an allocation request and submits an IO task to wait for and handle the
* response.
* This assumes that the colibri2 conference has already been created, i.e. [createRelay] must be called after at
* least one call to [sendAllocationRequest].
*/
internal fun createRelay(
/** The ID of the relay (i.e. the relayId of the remote [Bridge]). */
relayId: String,
/** Initial remote endpoints to be included in the relay. */
initialParticipants: List<ParticipantInfo>,
/**
* The single flag used internally to determine the ICE/DTLS/WS roles of the relay. The two sides in a relay
* connection should have different values for [initiator].
*/
initiator: Boolean,
/** The mesh ID of this relay connection */
meshId: String?
) {
logger.info(
"Creating relay $relayId (initiator=$initiator), initial participants: ${initialParticipants.map { it.id }}"
)
if (relays.containsKey(relayId)) {
throw IllegalStateException("Relay $relayId already exists (bridge=${this.relayId}")
}
val relay = Relay(relayId, initiator, meshId)
relays[relayId] = relay
relay.start(initialParticipants)
}
/**
* Updates the sources for a specific endpoint on a specific relay. Also used to create a new remote endpoint in
* the relay (when [create] is true).
*/
internal fun updateRemoteParticipant(
participantInfo: ParticipantInfo,
/** The ID of the relay on which to add/update an endpoint. */
relayId: String,
/** Whether a new relay endpoint should be created, or an existing one updated. */
create: Boolean
) {
logger.debug { "Updating remote participant ${participantInfo.id} on $relayId" }
relays[relayId]?.updateParticipant(participantInfo, create)
?: throw IllegalStateException("Relay $relayId doesn't exist (bridge=${this.relayId})")
}
/** Expires a set of endpoints on a specific relay. */
internal fun expireRemoteParticipants(participants: List<ParticipantInfo>, relayId: String) {
logger.debug { "Expiring remote participants on $relayId: ${participants.map { it.id }}" }
relays[relayId]?.expireParticipants(participants)
?: throw IllegalStateException("Relay $relayId doesn't exist (bridge=${this.relayId})")
}
/** Sets the remote side transport information for a specific relay. */
internal fun setRelayTransport(
/** The transport information of the other bridge. */
transport: IceUdpTransportPacketExtension,
relayId: String
) {
logger.info("Setting relay transport for $relayId")
logger.debug { "Setting relay transport for $relayId: ${transport.toXML()}" }
relays[relayId]?.setTransport(transport)
?: throw IllegalStateException("Relay $relayId doesn't exist (bridge=${this.relayId}")
}
internal fun expireAllRelays() = expireRelays(relays.keys.toList())
internal fun expireRelay(relayId: String) = expireRelays(listOf(relayId))
private fun expireRelays(relayIds: List<String>) {
if (relayIds.isEmpty()) {
logger.debug("No relays to expire.")
return
}
logger.info("Expiring relays: $relayIds")
val request = createRequest()
relayIds.forEach {
request.addRelay(
Colibri2Relay.getBuilder().apply {
setId(it)
setExpire(true)
}.build()
)
}
relayIds.forEach { relays.remove(it) }
sendRequest(request.build(), "expireRelays")
}
/**
* Send an IQ async, and handle timeouts and errors: timeouts are just logged, while errors trigger a session
* failure.
*
* @param onSuccess invoked with the response when the bridge answered successfully. Note that this runs on
* [org.jitsi.jicofo.util.TaskPools.ioPool], so responses to two requests sent in order may be handled out of
* order — anything order-sensitive needs its own guard.
*/
private fun sendRequest(iq: IQ, name: String, onSuccess: ((ConferenceModifiedIQ) -> Unit)? = null) {
logger.debug { "Sending $name request: ${iq.toXML()}" }
xmppConnection.sendIqAndHandleResponseAsync(iq) { response ->
if (response == null) {
logger.info("$name request timed out. Ignoring.")
return@sendIqAndHandleResponseAsync
}
response.error?.let { error ->
val reason = error.getExtension<Colibri2Error>(
Colibri2Error.ELEMENT,
Colibri2Error.NAMESPACE
)?.reason
val endpointId = error.getExtension<Colibri2Endpoint>(
Colibri2Endpoint.ELEMENT,
Colibri2Endpoint.NAMESPACE
)?.id
// If colibri2 error extension is present then the message came from
// a jitsi-videobridge instance. Otherwise, it might come from another component
// (e.g. the XMPP server or MUC component).
val reInvite = reason == Colibri2Error.Reason.UNKNOWN_ENDPOINT && endpointId != null
if (reInvite) {
logger.warn(
"Endpoint [$endpointId] is not found, session failed: ${error.toXML()}, " +
"request was: ${iq.toXML()}"
)
colibriSessionManager.endpointFailed(endpointId!!)
} else {
logger.error("Received error response for $name, session failed: ${error.toXML()}")
colibriSessionManager.sessionFailed(this@Colibri2Session)
}
return@sendIqAndHandleResponseAsync
}
if (response !is ConferenceModifiedIQ) {
logger.error("Received response with unexpected type ${response.javaClass.name}")
colibriSessionManager.sessionFailed(this@Colibri2Session)
} else {
logger.debug { "Received $name response: ${response.toXML()}" }
onSuccess?.invoke(response)
}
}
}
override fun toString() = "Colibri2Session[bridge=${bridge.jid.resourceOrNull}, id=$id]"
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
set<ObjectNode>("bridge", bridge.debugState)
put("id", id)
set<ObjectNode>("feedback_sources", feedbackSources.toJson())
put("created", created)
put("recently_added_endpoints", recentlyAddedEndpointCount)
set<ObjectNode>(
"relays",
JsonNodeFactory.instance.objectNode().apply {
relays.values.forEach { set<ObjectNode>(it.relayId, it.toJson()) }
}
)
}
/**
* Represents a colibri2 relay connection to another bridge.
*/
inner class Relay(
/** The relayId of the remote bridge. */
override val relayId: String,
/**
* A flag used to determine the roles. The associated [Relay] for the remote side should have the opposite
* value.
*/
initiator: Boolean,
override val meshId: String? = null
) : CascadeLink {
// TODO: One of the ways to select the ICE/DTLS roles might save an RTT. Which one?
private val useUniquePort = initiator
private val iceControlling = initiator
private val dtlsSetup = if (initiator) "active" else "passive"
private val bridgeChannelActive = initiator
private val sctpBridgeChannel = OctoConfig.config.sctpDatachannels
private val logger = createChildLogger(this@Colibri2Session.logger).apply { addContext("relay", relayId) }
/** Send a request to allocate a new relay, and submit a task to wait for a response. */
internal fun start(initialParticipants: List<ParticipantInfo>) {
val request = buildCreateRelayRequest(initialParticipants)
logger.trace { "Sending create relay: ${request.toXML()}" }
xmppConnection.sendIqAndHandleResponseAsync(request) { response ->
// Wait for a response to the relay allocation request. When a response is received, parse the contained
// transport and forward it to the associated [Relay] for the remote side via [colibriSessionManager]
logger.trace { "Received response: ${response?.toXML()}" }
if (response !is ConferenceModifiedIQ) {
logger.error("Received error: ${response?.toXML() ?: "timeout"}")
colibriSessionManager.sessionFailed(this@Colibri2Session)
return@sendIqAndHandleResponseAsync
}
// TODO: We just assume that the response has a single [Colibri2Relay].
val transport = response.relays.firstOrNull()?.transport
?: run {
logger.error("No transport in response: ${response.toXML()}")
colibriSessionManager.sessionFailed(this@Colibri2Session)
return@sendIqAndHandleResponseAsync
}
val iceUdpTransport = transport.iceUdpTransport
if (iceUdpTransport == null) {
logger.error("Response has no iceUdpTransport")
colibriSessionManager.sessionFailed(this@Colibri2Session)
return@sendIqAndHandleResponseAsync
}
// Forward the response to the corresponding [Colibri2Session]
colibriSessionManager.setRelayTransport(this@Colibri2Session, iceUdpTransport, relayId)
}
}
/** Sends a colibri2 message setting/updating the remote-side transport of this relay. */
fun setTransport(
/**
* The transport info as received by the remote side. We update some of the fields as required based on the
* ICE/DTLS/WS roles.
*/
transport: IceUdpTransportPacketExtension
) {
// We always expect the bridge to advertise its DTLS setup as "actpass". Here we override it to set one
// side as "active" and the other as "passive".
transport.getChildExtensionsOfType(DtlsFingerprintPacketExtension::class.java).forEach {
if (it.setup != "actpass") {
logger.error("Response has an unexpected dtls setup field: ${it.setup}")
return
}
logger.info("Setting setup=$dtlsSetup for $relayId")
it.setup = dtlsSetup
}
// The bridge always advertises a websocket if we're not using SCTP, but we want only one side to act as a
// client.
// The bridge will always act as a client if the signaled transport includes a websocket, so here we make
// sure it is only included to one of the sides.
if (sctpBridgeChannel || !bridgeChannelActive) {
transport.getChildExtensionsOfType(WebSocketPacketExtension::class.java).forEach {
transport.removeChildExtension(it)
}
}
val request = createRequest()
val relay = Colibri2Relay.getBuilder().apply {
setId(relayId)
setCreate(false)
}
relay.setTransport(Transport.getBuilder().apply { setIceUdpExtension(transport) }.build())
request.addRelay(relay.build())
sendRequest(request.build(), "Relay.setTransport")
}
fun toJson() = JsonNodeFactory.instance.objectNode().apply {
put("id", relayId)
put("use_unique_port", useUniquePort)
put("ice_controlling", iceControlling)
put("dtls_setup", dtlsSetup)
put("bridge_channel_active", bridgeChannelActive)
put("sctp_bridge_channel", sctpBridgeChannel)
meshId?.let { put("mesh_id", it) }
}
/** Update or create a relay endpoint for a specific participant. */
fun updateParticipant(participant: ParticipantInfo, create: Boolean) {
val request = createRequest()
val relay = Colibri2Relay.getBuilder().apply {
setId(relayId)
}
val endpoints = Endpoints.getBuilder()
endpoints.addEndpoint(participant.toEndpoint(create = create, expire = false).build())
relay.setEndpoints(endpoints.build())
request.addRelay(relay.build())
sendRequest(request.build(), "Relay.updateParticipant")
}
/** Expire relay endpoints for a set of participants. */
fun expireParticipants(participants: List<ParticipantInfo>) {
if (participants.all { it.visitor }) {
return
}
val request = createRequest()
val relay = Colibri2Relay.getBuilder().apply { setId(relayId) }
val endpoints = Endpoints.getBuilder()
participants.forEach {
if (!it.visitor) {
endpoints.addEndpoint(it.toEndpoint(create = false, expire = true).build())
}
}
relay.setEndpoints(endpoints.build())
request.addRelay(relay.build())
sendRequest(request.build(), "Relay.expireParticipants")
}
/** Create a request to create a relay (this is just the initial request). */
private fun buildCreateRelayRequest(participants: Collection<ParticipantInfo>): ConferenceModifyIQ {
val request = createRequest()
val relay = Colibri2Relay.getBuilder().apply {
setId(relayId)
meshId?.let { setMeshId(it) }
setCreate(true)
}
relay.addMedia(
Media.getBuilder().apply {
setType(MediaType.AUDIO)
CodecUtil.createAudioPayloadTypeExtensions().forEach { addPayloadType(it) }
CodecUtil.createAudioRtpHdrExtExtensions().forEach { addRtpHdrExt(it) }
if (Config.Companion.config.extmapAllowMixed) {
setExtmapAllowMixed(ExtmapAllowMixedPacketExtension())
}
}.build()
)
relay.addMedia(
Media.getBuilder().apply {
setType(MediaType.VIDEO)
CodecUtil.createVideoPayloadTypeExtensions().forEach { addPayloadType(it) }
CodecUtil.createVideoRtpHdrExtExtensions().forEach { addRtpHdrExt(it) }
if (Config.Companion.config.extmapAllowMixed) {
setExtmapAllowMixed(ExtmapAllowMixedPacketExtension())
}
}.build()
)
val endpoints = Endpoints.getBuilder()
participants.filter { !it.visitor }.forEach {
endpoints.addEndpoint(it.toEndpoint(create = true, expire = false).build())
}
relay.setEndpoints(endpoints.build())
relay.setTransport(
Transport.getBuilder().apply {
setUseUniquePort(useUniquePort)
setIceControlling(iceControlling)
if (sctpBridgeChannel) {
val sctp = Sctp.Builder()
sctp.setRole(if (bridgeChannelActive) Sctp.Role.CLIENT else Sctp.Role.SERVER)
setSctp(sctp.build())
}
}.build()
)
request.addRelay(relay.build())
return request.build()
}
}
}
private fun ConferenceModifyIQ.Builder.addExpire(endpointId: String) = addEndpoint(
Colibri2Endpoint.getBuilder().apply {
setId(endpointId)
setExpire(true)
}.build()
)

View File

@@ -0,0 +1,35 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import org.jitsi.jicofo.conference.source.ConferenceSourceMap
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
/**
* Describes the resources allocated on a bridge for a single endpoint.
*/
data class ColibriAllocation(
/** The sources advertised by the bridge. */
val sources: ConferenceSourceMap,
/** Encodes the bridge-side transport information (ICE candidates, DTLS fingerprint, etc.). */
val transport: IceUdpTransportPacketExtension,
/** The region of the bridge */
val region: String?,
/** An identifier for the session */
val bridgeSessionId: String?,
/** The SCTP port (used for both local and remote port) advertised by the bridge, if any */
val sctpPort: Int?
)

View File

@@ -0,0 +1,171 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.jicofo.MediaType
import org.jitsi.jicofo.bridge.Bridge
import org.jitsi.jicofo.bridge.ConferenceBridgeProperties
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.utils.TemplatedUrl
import org.jitsi.xmpp.extensions.colibri2.InitialLastN
import org.jitsi.xmpp.extensions.colibri2.Media
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
interface ColibriSessionManager {
fun addListener(listener: Listener)
fun removeListener(listener: Listener)
/** Expire all colibri sessions. */
fun expire()
/** Remove a participant, expiring all resources allocated for it */
fun removeParticipant(participantId: String)
fun mute(participantId: String, doMute: Boolean, mediaType: MediaType): Boolean =
mute(setOf(participantId), doMute, mediaType)
fun mute(participantIds: Set<String>, doMute: Boolean, mediaType: MediaType): Boolean
val bridgeCount: Int
val bridgeRegions: Set<String>
/** Get the list of participant IDs that are currently allocated on a specific [Bridge]. */
fun getParticipants(bridge: Bridge): List<String>
@Throws(ColibriAllocationFailedException::class, BridgeSelectionFailedException::class)
fun allocate(participant: ParticipantAllocationParameters): ColibriAllocation
fun getBridges(): Map<Bridge, ConferenceBridgeProperties>
fun updateParticipant(
participantId: String,
transport: IceUdpTransportPacketExtension? = null,
sources: EndpointSourceSet? = null,
initialLastN: InitialLastN? = null,
suppressLocalBridgeUpdate: Boolean = false
)
/**
* Ask the bridge to perform an in-place ICE restart for a participant. The bridge creates a new ICE agent with
* fresh credentials (while the existing one keeps carrying media) and answers with its new transport, which is
* relayed back to the participant via [Listener.endpointIceRestarted].
*/
fun restartIce(participantId: String)
fun getBridgeSessionId(participantId: String): Pair<Bridge?, String?>
fun setTranscriberUrl(
url: TemplatedUrl?,
customHeaders: Map<String, String>? = null,
urlParams: Map<String, String>? = null
)
/**
* Set the languages the transcriber should translate its transcripts into (text translation, not the
* speech-to-speech translation that [setTranslator] controls).
*
* The languages are signaled as the `requests` of the transcriber connect, which the bridge forwards to the
* transcriber. [languages] is the full set, so an empty set stops all translation. Calling this before the
* transcriber connect exists is fine: the languages are kept and applied when it is created.
*/
fun setTextTranslationLanguages(languages: Set<String>)
/**
* Enable, update, or disable live translation.
*
* @param url the websocket URL template the bridge(s) should connect to, or null to disable translation.
* @param requests the per-sender translation requests. How these are placed on bridges depends on the configured
* [org.jitsi.jicofo.TranslationConfig.mode].
*/
fun setTranslator(
url: TemplatedUrl?,
requests: List<TranslationRequest> = emptyList(),
customHeaders: Map<String, String>? = null
)
/**
* Stop using [bridge], expiring all endpoints on it (e.g. because it was detected to have failed).
* @return the list of participant IDs which were on the removed bridge and now need to be re-invited.
*/
fun removeBridge(bridge: Bridge): List<String>
val debugState: ObjectNode
/**
* Interface for events fired by [ColibriSessionManager].
*/
interface Listener {
/** The number of bridges changed. */
fun bridgeCountChanged(bridgeCount: Int)
fun bridgeSelectionFailed() {}
fun bridgeSelectionSucceeded() {}
/** A bridge was removed from the conference due to a failure. **/
fun bridgeRemoved(
/** The bridge that was removed. */
bridge: Bridge,
/** The list of participant IDs which were on the removed bridge. **/
participantIds: List<String>
)
/** Endpoint removed due to a failure e.g. unknown endpoint */
fun endpointRemoved(endpointId: String)
/**
* The bridge performed an ICE restart for an endpoint and rotated its own ICE credentials. The new
* transport has to be signalled to the endpoint, whose connectivity checks are addressed to it.
*/
fun endpointIceRestarted(endpointId: String, transport: IceUdpTransportPacketExtension) {}
/**
* The bridge did not perform the ICE restart that was requested for an endpoint. The bridge signals this
* by answering with no transport rather than with an error (so that one endpoint can not fail a
* conference-modify carrying updates for others), which leaves the endpoint waiting for a restart that
* will never arrive. Recover it the way it would have been recovered without an in-place restart.
*/
fun endpointIceRestartFailed(endpointId: String) {}
}
}
data class ParticipantAllocationParameters(
val id: String,
val statsId: String?,
val region: String?,
val sources: EndpointSourceSet,
val useSsrcRewriting: Boolean,
val useRtpMidDemux: Boolean,
val forceMuteAudio: Boolean,
val forceMuteVideo: Boolean,
val useSctp: Boolean,
val visitor: Boolean,
val supportsPrivateAddresses: Boolean,
val diarize: Boolean,
val medias: Set<Media>
)
/**
* A request to translate one sender's audio into one or more languages.
*
* @param senderEndpointId the endpoint id of the sender whose audio is translated (used to find its local bridge).
* @param sourceName the sender's base audio source name, exported to the translator.
* @param syntheticSourceNames the synthetic, language-encoded source names requested back from the translator.
*/
data class TranslationRequest(
val senderEndpointId: String,
val sourceName: String,
val syntheticSourceNames: List<String>
)

View File

@@ -0,0 +1,69 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import org.jitsi.xmpp.extensions.colibri2.Connect
import java.net.URI
/**
* A description of a single desired colibri2 `<connect>` on a bridge, with its [url] already resolved for that bridge.
* The identity is [id]; a session keeps its connects keyed by it and signals add/update/remove as deltas. This is the
* jicofo-side desired state, separate from the wire [Connect] (which also carries the create/expire delta markers).
*/
data class ConnectSpec(
val id: String,
val url: URI,
val type: Connect.Types,
val audio: Boolean = true,
/** Source names exported to (sent to) the peer. */
val exports: List<String> = emptyList(),
/** Source names requested from (received from) the peer. */
val requests: List<String> = emptyList(),
val httpHeaders: Map<String, String> = emptyMap(),
val ping: Ping? = null
) {
data class Ping(val interval: Int, val timeout: Int)
/** Build the wire [Connect] for this spec with the given delta markers. */
fun toConnect(create: Boolean = false, expire: Boolean = false): Connect = Connect(
id = id,
url = url,
protocol = Connect.Protocols.MEDIAJSON,
type = type,
audio = audio,
create = create,
expire = expire
).apply {
httpHeaders.forEach { (name, value) -> addHttpHeader(name, value) }
if (exports.isNotEmpty()) setExports(exports)
if (requests.isNotEmpty()) setRequests(requests)
ping?.let { setPing(it.interval, it.timeout) }
}
/**
* Whether [other] (which must have the same [id]) describes the same connect, so no update needs to be signaled.
* Source-name lists are compared as sets, since their order is not significant.
*/
fun sameAs(other: ConnectSpec): Boolean = url == other.url &&
type == other.type &&
audio == other.audio &&
httpHeaders == other.httpHeaders &&
ping == other.ping &&
exports.toSet() == other.exports.toSet() &&
requests.toSet() == other.requests.toSet()
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
/** Bridge selection failed, i.e. there were no bridges available. */
class BridgeSelectionFailedException : Exception("Bridge selection failed")
open class ColibriAllocationFailedException(
message: String = "",
val removeBridge: Boolean = false
) : Exception(message)
class ConferenceAlreadyExistsException(
message: String,
removeBridge: Boolean
) : ColibriAllocationFailedException(message, removeBridge)

View File

@@ -0,0 +1,149 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.conference.source.ConferenceSourceMap
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.jicofo.conference.source.Source
import org.jitsi.jicofo.conference.source.SsrcGroup
import org.jitsi.xmpp.extensions.colibri2.Capability
import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ
import org.jitsi.xmpp.extensions.colibri2.MediaSource
import org.jitsi.xmpp.extensions.colibri2.Sources
import org.jitsi.xmpp.extensions.colibri2.Transport
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.packet.IQ
/** Read the [IceUdpTransportPacketExtension] for an endpoint with ID [endpointId] (or null if missing). */
fun ConferenceModifiedIQ.parseTransport(endpointId: String): Transport? =
endpoints.find { it.id == endpointId }?.transport
/**
* Reads the feedback sources (at the "conference" level) and parses them into a [ConferenceSourceMap].
* Uses the special [SSRC_OWNER_JVB] JID as the "owner" and appends the msid, since this is how jitsi-meet clients
* expect the bridge's sources to be signaled.
*/
fun ConferenceModifiedIQ.parseSources(): ConferenceSourceMap {
val parsedSources = ConferenceSourceMap()
sources?.mediaSources?.forEach { mediaSource ->
mediaSource.sources.forEach { sourcePacketExtension ->
val msid = "mixedmslabel mixedlabel${mediaSource.type}0"
val source = Source(sourcePacketExtension.ssrc, mediaSource.type, sourcePacketExtension.name, msid)
parsedSources.add(SSRC_OWNER_JVB, EndpointSourceSet(source))
}
mediaSource.ssrcGroups.forEach {
parsedSources.add(SSRC_OWNER_JVB, EndpointSourceSet(SsrcGroup.fromPacketExtension(it, mediaSource.type)))
}
}
return parsedSources
}
fun EndpointSourceSet.toColibriMediaSources(endpointId: String): Sources {
val mediaSources: MutableMap<String, MediaSource.Builder> = mutableMapOf()
// We use the signaled "name" of the source at the colibri2 source ID. If a source name isn't signaled, we use a
// default ID of "endpointId-v0". This allows backwards compat with clients that don't signal source names
// (and only support a single source).
sources.forEach { source ->
val sourceId = source.name ?: Source.nameForIdAndMediaType(endpointId, source.mediaType, 0)
val mediaSource = mediaSources.computeIfAbsent(sourceId) {
MediaSource.getBuilder()
.setType(source.mediaType)
.setId(sourceId)
// Synthetic sources (e.g. translated audio) are injected by the server and the bridge must only
// forward them to endpoints explicitly subscribed to them.
.setSynthetic(source.synthetic)
}
mediaSource.addSource(source.toPacketExtension(encodeMsid = false))
}
ssrcGroups.forEach group@{ ssrcGroup ->
if (ssrcGroup.ssrcs.isEmpty()) return@group
val firstSource = sources.firstOrNull { ssrcGroup.ssrcs.contains(it.ssrc) }
?: throw IllegalStateException("An SsrcGroup in an EndpointSourceSet has an SSRC without a Source")
val sourceId = firstSource.name ?: Source.nameForIdAndMediaType(endpointId, ssrcGroup.mediaType, 0)
val mediaSource = mediaSources.computeIfAbsent(sourceId) {
MediaSource.getBuilder()
.setType(ssrcGroup.mediaType)
.setId(sourceId)
}
mediaSource.addSsrcGroup(ssrcGroup.toPacketExtension())
}
val sources = Sources.getBuilder()
mediaSources.values.forEach { sources.addMediaSource(it.build()) }
return sources.build()
}
/** Create a [Colibri2Endpoint] for a specific [ParticipantInfo]. */
internal fun ParticipantInfo.toEndpoint(
/** Whether the request should have the "create" flag set. */
create: Boolean,
/** Whether the request should have the "expire" flag set. */
expire: Boolean
): Colibri2Endpoint.Builder = Colibri2Endpoint.getBuilder().apply {
setId(id)
if (create) {
setCreate(true)
setStatsId(statsId)
addCapability(Capability.CAP_SOURCE_NAME_SUPPORT)
if (supportsPrivateAddresses) {
addCapability(Capability.CAP_PRIVATE_ADDRESS_CONNECTIVITY)
}
if (useSsrcRewriting) {
addCapability(Capability.CAP_SSRC_REWRITING_SUPPORT)
}
if (useRtpMidDemux) {
addCapability(Capability.CAP_RTP_MID_DEMUX_SUPPORT)
}
if (diarize) {
// Call setDiarize on the Colibri2Endpoint.Builder directly: the fluent builder chain returns a parent
// Builder type after setId(...) which does not expose setDiarize.
setDiarize(true)
}
}
// TODO: find a way to signal sources only when they change? Or is this already the case implicitly?
if (!expire) {
setSources(sources.toColibriMediaSources(id))
}
if (expire) {
setExpire(true)
}
}
internal fun AbstractXMPPConnection.sendIqAndHandleResponseAsync(iq: IQ, block: (IQ?) -> Unit) {
val stanzaCollector = createStanzaCollectorAndSend(iq)
TaskPools.ioPool.submit {
try {
block(stanzaCollector.nextResult())
} finally {
stanzaCollector.cancel()
}
}
}
/**
* The constant value used as owner attribute value of
* {@link SSRCInfoPacketExtension} for the SSRC which belongs to the JVB.
*/
const val SSRC_OWNER_JVB = "jvb"

View File

@@ -0,0 +1,78 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
/**
* Represents the information for a specific participant/endpoint needed for colibri2.
*/
class ParticipantInfo(
parameters: ParticipantAllocationParameters,
var session: Colibri2Session
) {
val id = parameters.id
val statsId = parameters.statsId
val useSctp = parameters.useSctp
val medias = parameters.medias
val supportsPrivateAddresses = parameters.supportsPrivateAddresses
val useSsrcRewriting = parameters.useSsrcRewriting
val useRtpMidDemux = parameters.useRtpMidDemux
val visitor = parameters.visitor
val diarize = parameters.diarize
var audioMuted = parameters.forceMuteAudio
var videoMuted = parameters.forceMuteVideo
var sources = parameters.sources
/**
* The `ice-generation` of the most recent ICE restart for which we relayed the bridge's rotated transport
* back to this participant, used to discard responses that arrive out of order. See
* [ColibriV2SessionManager.endpointIceRestarted]. Guarded by the session manager's lock.
*/
var lastRelayedIceGeneration = IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED
/**
* The `ice-generation` of the most recent ICE restart whose answer we have already forwarded to the bridge,
* so that only the first set of new credentials of a round is tagged. See
* [ColibriV2SessionManager.stampIceGeneration]. Guarded by the session manager's lock.
*/
var lastAnsweredIceGeneration = IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED
/**
* The ICE ufrag of the last transport we forwarded to the bridge for this participant. The answer to an ICE
* restart is recognized by carrying credentials which are not these. Guarded by the session manager's lock.
*/
var lastSignaledIceUfrag: String? = null
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("id", id)
put("stats_id", statsId.toString())
set<ObjectNode>("sources", sources.toJson())
put("bridge", session.bridge.jid.resourceOrNull.toString())
put("audio_muted", audioMuted)
put("video_muted", videoMuted)
put("private_addresses", supportsPrivateAddresses)
put("ssrc_rewriting", useSsrcRewriting)
put("rtp_mid_demux", useRtpMidDemux)
put("visitor", visitor)
put("diarize", diarize)
}
}

View File

@@ -0,0 +1,181 @@
// Default values for configuration read by classes in jicofo-selector. Defaults for other jicofo.* sections live in
// the reference.conf of the module that owns them (jicofo-common, jicofo).
jicofo {
// Configuration related to jitsi-videobridge
bridge {
// The maximum number of participants in a single conference to put on one bridge (use -1 for no maximum).
max-bridge-participants = 80
// The maximum number of participants that a single conference may add to a single bridge within
// [max-bridge-participants-interval]. A bridge which has reached this limit is considered overloaded *for that
// conference only* when selecting a bridge for a new participant; other conferences, and all other bridge-level
// logic (stress, sorting, load redistribution) are unaffected.
//
// This limits the overshoot that happens when a conference bursts: a bridge reports its stress level only every
// 5-10 seconds, so during a burst of joins the reported stress is stale by construction and jicofo can keep
// selecting the same bridge long after it is full. Note this is a limit on the *rate* at which a conference grows
// on a bridge, so it does not affect a conference which grows slowly (where the stress feedback has caught up),
// and it does not affect many small conferences sharing a bridge (each has a low rate of its own).
//
// Use -1 to disable. A suggested value for a production deployment is 30 (with the default 1 minute interval).
// Note that [max-bridge-participants] remains in effect as a hard limit.
max-bridge-participants-per-interval = -1
// The interval over which [max-bridge-participants-per-interval] is enforced. It should comfortably cover the
// interval at which bridges report their stress level (5-10 seconds).
max-bridge-participants-interval = 1 minute
// The default assumed average stress per participant. This value is only used when a bridge does not report its
// own value.
average-participant-stress = 0.01
// The assumed time that an endpoint takes to start contributing fully to the load on a bridge. To avoid allocating
// a burst of endpoints to the same bridge, the bridge stress is adjusted by adding the number of new endpoints
// in the last [participant-rampup-time] multiplied by [average-participant-stress].
participant-rampup-interval = 20 seconds
// The stress level above which a bridge is considered overstressed.
stress-threshold = 0.8
// Whether to prefix the bridge session ID (used e.g. in ICE restarts) with the bridge's name. This helps
// identify the bridge from the session ID alone, e.g. in client-side logs.
include-bridge-name-in-session-id = true
// The amount of to wait before retrying using a failed bridge.
failure-reset-threshold = 1 minute
// The bridge selection strategy. The built-in strategies are:
// SingleBridgeSelectionStrategy: Use the least loaded bridge, do not split a conference between bridges (Octo).
// SplitBridgeSelectionStrategy: Use a separate bridge for each participant (for testing).
// RegionBasedBridgeSelectionStrategy: Attempt to put each participant in a bridge in their local region (i.e. use
// Octo for geo-location).
// IntraRegionBridgeSelectionStrategy: Use additional bridges only when a bridge becomes overloaded (i.e. use Octo
// only for load balancing). This hasn't been tested much; use RegionBasedBridgeSelectionStrategy unless there's a
// reason not to.
// VisitorSelectionStrategy: Use separate strategies and bridges for visitors and participants. In this case,
// visitor-selection-strategy and participant-selection-strategy should be defined
//
// Additionally, you can use the fully qualified class name for custom BridgeSelectionStrategy implementations.
selection-strategy = SingleBridgeSelectionStrategy
// Uncomment and define these if your primary selection strategy is VisitorSelectionStrategy
# visitor-selection-strategy = RegionBasedBridgeSelectionStrategy
# participant-selection-strategy = RegionBasedBridgeSelectionStrategy
// The topology selection strategy.
topology-strategy = SingleMeshTopologyStrategy
// A partition of regions into groups that are "close" to each other (regions not specified here will be assumed
// to be in a group of their own). When selecting a bridge for a region R, existing conference bridge in R's group
// of regions will all be considered to match the region.
// For example, with the sample values below, if a conference has a bridge in "us-east" and a participant in
// "us-west" joins, the bridge in "us-east" will be used because "us-east" and "us-west" are in the same group.
// region-groups = [
// [ "us-east", "us-west" ],
// [ "eu-central", "eu-west"]
// ]
health-checks {
// Whether jicofo should perform periodic health checks to the connected bridges.
enabled = true
// The interval at which to perform health checks.
interval = 10 seconds
// When a health checks times out, jicofo will retry and only consider it fail after the retry fails. This
// configures the delay between the original health check timing out and the second health check being sent.
// It is a duration and defaults to half the [interval].
# retry-delay = 5 seconds
// Use the lack of presence to infer unhealthy status instead of sending explicit health check requests.
use-presence = false
// A bridge will be consider unhealthy unless we've received its presence in that period.
presence-timeout = 45 seconds
}
// The JID of the MUC to be used as a brewery for bridge instances.
# brewery-jid = jvbbrewery@example.com
// The XMPP connection to use to communicate with Jitsi Videobridge instances.
// Either `Client` or `Service` (case-sensitive). See the corresponding XMPP connection configuration under `xmpp`.
// Note that if no separate Service connection has been configured, all services will automatically use the
// Client connection.
xmpp-connection-name = Service
// Detect bridge failure based on endpoints that request a restart due to an ICE failure.
ice-failure-detection {
enabled = false
// Count the number of restart requests in the last `interval`.
interval = 2 minutes
// The minimum number of endpoints on the bridge before this check is enabled.
min-endpoints = 30
// The threshold, in terms of fraction of endpoints that requested a restart in the last `interval`, above which
// the bridge will be considered failing ICE.
threshold = 0.1
// Once the "failing ICE" state is entered, the bridge will stay in it at least this long (refreshed as long as
// the condition is met).
timeout = 10 minutes
}
load-redistribution {
// Whether automatic redistribution of endpoints away from overloaded bridges is enabled.
enabled = false
// The interval at which to run.
interval = 1 minute
// The threshold above which to redistribute endpoints away from a bridge. This needs to be higher than
// jicofo.bridge.stress-threshold.
stress-threshold = 0.9
// The amount of time to wait before moving more endpoints away from a bridge.
timeout = 3 minutes
// The number of endpoints to move away from a bridge at a time.
endpoints = 10
}
}
transcription {
# A templated URL to use for backend (non-jigasi) transcription. The strings "{{MEEETING_ID}}" and "{{REGION}}"
# will be replaced with the unique meeting ID and the region of the bridge used for transcription. The region is
# optional. Note no whitespace is allowed in the templates, they have to match exactly.
// url-template = "wss://{{REGION}}.example.com/recorder/{{MEETING_ID}}"
# HTTP headers to include when connecting to the transcription service. These can include authentication tokens,
# API keys, content type specifications, etc.
http-headers {
// "Authorization" = "Bearer your-api-key"
// "Sec-Websocket-Protocol" = "my proto"
}
# Ping configuration for the transcription service connection
ping {
# Whether to enable ping messages
enabled = true
# Interval between ping messages
interval = 10 seconds
# Timeout for ping responses
timeout = 3 seconds
}
}
translation {
# A templated URL for the live-translation websocket. "{{MEETING_ID}}" and "{{REGION}}" are substituted (REGION is
# optional, set to the bridge's region). When unset, live translation is disabled. No whitespace in the templates.
// url-template = "wss://{{REGION}}.example.com/translate/{{MEETING_ID}}"
# HTTP headers to include when connecting to the translation service.
http-headers {
// "Authorization" = "Bearer your-api-key"
}
# How translation connects are placed on bridges:
# per-source - each sender's audio is translated by a connect on its own (local) bridge. A sender requesting
# more than max-languages-per-connect target languages is split across multiple connects on that
# bridge, so a bridge has one or more websockets per source.
# single-bridge - a single connect on one selected bridge handles all sources and languages (mirrors the
# transcriber selection).
mode = "per-source"
# The maximum number of target languages handled by a single translation connect. ONLY applies in "per-source"
# mode; a sender requesting more languages is split across multiple connects on its bridge. Ignored in
# "single-bridge" mode.
max-languages-per-connect = 5
# Ping configuration for the translation service connection (keepalive to avoid idle-timeout).
ping {
# Whether to enable ping messages
enabled = true
# Interval between ping messages
interval = 10 seconds
# Timeout for ping responses
timeout = 3 seconds
}
}
}

View File

@@ -0,0 +1,33 @@
import io.kotest.core.config.AbstractProjectConfig
import org.jitsi.jicofo.metrics.JicofoMetricsContainer
import org.jitsi.metaconfig.MetaconfigSettings
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Note: jicofo and jicofo-selector still share packages, so the class name is change to avoid conflicts.
*/
class JicofoSelectorKotestProjectConfig : AbstractProjectConfig() {
override suspend fun beforeProject() = super.beforeProject().also {
// The only purpose of config caching is performance. We always want caching disabled in tests (so we can
// freely modify the config without affecting other tests executing afterwards).
MetaconfigSettings.cacheEnabled = false
JicofoMetricsContainer.instance.checkForNameConflicts = false
JicofoMetricsContainer.instance.metricsUpdater.disablePeriodicUpdates = true
}
}

View File

@@ -0,0 +1,257 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.xmpp.RoomMetadata
import java.time.Duration
class TranscriptionConfigTest : ShouldSpec() {
init {
context("http-headers") {
context("With no headers configured") {
withNewConfig("") {
TranscriptionConfig.config.httpHeaders shouldBe emptyMap()
}
}
context("With headers configured") {
withNewConfig(
"""
jicofo.transcription.http-headers {
"Authorization" = "Bearer token123"
"Content-Type" = "application/json"
"User-Agent" = "JitsiMeet/1.0"
"X-API-Key" = "secret-key"
}
""".trimIndent()
) {
TranscriptionConfig.config.httpHeaders shouldBe mapOf(
"Authorization" to "Bearer token123",
"Content-Type" to "application/json",
"User-Agent" to "JitsiMeet/1.0",
"X-API-Key" to "secret-key"
)
}
}
context("With empty headers block") {
withNewConfig(
"""
jicofo.transcription.http-headers { }
""".trimIndent()
) {
TranscriptionConfig.config.httpHeaders shouldBe emptyMap()
}
}
context("With single header") {
withNewConfig(
"""
jicofo.transcription.http-headers {
"Authorization" = "Bearer single-token"
}
""".trimIndent()
) {
TranscriptionConfig.config.httpHeaders shouldBe mapOf(
"Authorization" to "Bearer single-token"
)
}
}
}
context("url-template with headers") {
context("Both URL template and headers configured") {
withNewConfig(
"""
jicofo.transcription {
url-template = "wss://{{REGION}}.example.com/recorder/{{MEETING_ID}}"
http-headers {
"Authorization" = "Bearer api-token"
"Content-Type" = "application/json"
}
}
""".trimIndent()
) {
val config = TranscriptionConfig.config
// Test URL functionality still works
val url = config.getUrl("test-meeting-123")
url?.resolve("REGION", "us-west")?.toString() shouldBe
"wss://us-west.example.com/recorder/test-meeting-123"
// Test headers are available
config.httpHeaders shouldBe mapOf(
"Authorization" to "Bearer api-token",
"Content-Type" to "application/json"
)
}
}
}
context("processTranscriptionMetadata") {
val baseHeaders = mapOf("Base-Header" to "base-value", "Shared-Header" to "base-shared")
context("With null transcription") {
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(null, baseHeaders)
should("return null headers") { headers shouldBe null }
should("return null params") { params shouldBe null }
}
context("With transcription having no headers or params") {
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
RoomMetadata.Metadata.Transcription(),
baseHeaders
)
should("return null headers") { headers shouldBe null }
should("return null params") { params shouldBe null }
}
context("With URL params only") {
val urlParams = mapOf("key1" to "value1", "key2" to "value2")
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
RoomMetadata.Metadata.Transcription(urlParams = urlParams),
baseHeaders
)
should("return null headers") { headers shouldBe null }
should("return the URL params") { params shouldBe urlParams }
}
context("With custom headers only") {
val customHeaders = mapOf("X-Custom" to "custom-value", "Shared-Header" to "custom-shared")
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
RoomMetadata.Metadata.Transcription(httpHeaders = customHeaders),
baseHeaders
)
should("return merged headers with custom taking precedence") {
headers shouldBe mapOf(
"Base-Header" to "base-value",
"Shared-Header" to "custom-shared",
"X-Custom" to "custom-value"
)
}
should("return null params") { params shouldBe null }
}
context("With both custom headers and URL params") {
val customHeaders = mapOf("X-Custom" to "custom-value")
val urlParams = mapOf("param1" to "v1")
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
RoomMetadata.Metadata.Transcription(urlParams = urlParams, httpHeaders = customHeaders),
baseHeaders
)
should("return merged headers") {
headers shouldBe mapOf(
"Base-Header" to "base-value",
"Shared-Header" to "base-shared",
"X-Custom" to "custom-value"
)
}
should("return URL params") { params shouldBe urlParams }
}
}
context("ping") {
context("With default values") {
withNewConfig(
"""
jicofo.transcription.ping {
enabled = false
interval = 5 seconds
timeout = 3 seconds
}
""".trimIndent()
) {
val config = TranscriptionConfig.config
config.pingEnabled shouldBe false
config.pingInterval shouldBe Duration.ofSeconds(5)
config.pingTimeout shouldBe Duration.ofSeconds(3)
}
}
context("With ping enabled") {
withNewConfig(
"""
jicofo.transcription.ping {
enabled = true
interval = 10 seconds
timeout = 5 seconds
}
""".trimIndent()
) {
val config = TranscriptionConfig.config
config.pingEnabled shouldBe true
config.pingInterval shouldBe Duration.ofSeconds(10)
config.pingTimeout shouldBe Duration.ofSeconds(5)
}
}
context("With ping in milliseconds") {
withNewConfig(
"""
jicofo.transcription.ping {
enabled = true
interval = 1500 milliseconds
timeout = 500 milliseconds
}
""".trimIndent()
) {
val config = TranscriptionConfig.config
config.pingEnabled shouldBe true
config.pingInterval shouldBe Duration.ofMillis(1500)
config.pingTimeout shouldBe Duration.ofMillis(500)
}
}
context("Complete configuration with URL, headers and ping") {
withNewConfig(
"""
jicofo.transcription {
url-template = "wss://{{REGION}}.example.com/recorder/{{MEETING_ID}}"
http-headers {
"Authorization" = "Bearer api-token"
}
ping {
enabled = true
interval = 30 seconds
timeout = 10 seconds
}
}
""".trimIndent()
) {
val config = TranscriptionConfig.config
// Test URL
val url = config.getUrl("test-meeting-123")
url?.resolve("REGION", "us-west")?.toString() shouldBe
"wss://us-west.example.com/recorder/test-meeting-123"
// Test headers
config.httpHeaders shouldBe mapOf("Authorization" to "Bearer api-token")
// Test ping
config.pingEnabled shouldBe true
config.pingInterval shouldBe Duration.ofSeconds(30)
config.pingTimeout shouldBe Duration.ofSeconds(10)
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.xmpp.RoomMetadata
class TranslationConfigTest : ShouldSpec() {
init {
context("http-headers") {
context("With no headers configured") {
withNewConfig("") {
TranslationConfig.config.httpHeaders shouldBe emptyMap()
}
}
context("With headers configured") {
withNewConfig(
"""
jicofo.translation.http-headers {
"Authorization" = "Bearer token123"
"X-API-Key" = "secret-key"
}
""".trimIndent()
) {
TranslationConfig.config.httpHeaders shouldBe mapOf(
"Authorization" to "Bearer token123",
"X-API-Key" to "secret-key"
)
}
}
}
context("processTranslationMetadata") {
val baseHeaders = mapOf("Base-Header" to "base-value", "Shared-Header" to "base-shared")
context("With null translation") {
should("return null so callers fall back to config headers") {
TranslationConfig.processTranslationMetadata(null, baseHeaders) shouldBe null
}
}
context("With translation having no headers") {
should("return null so callers fall back to config headers") {
TranslationConfig.processTranslationMetadata(
RoomMetadata.Metadata.Translation(),
baseHeaders
) shouldBe null
}
}
context("With custom headers") {
val customHeaders = mapOf("X-Custom" to "custom-value", "Shared-Header" to "custom-shared")
should("return base headers merged with custom, custom taking precedence") {
TranslationConfig.processTranslationMetadata(
RoomMetadata.Metadata.Translation(httpHeaders = customHeaders),
baseHeaders
) shouldBe mapOf(
"Base-Header" to "base-value",
"Shared-Header" to "custom-shared",
"X-Custom" to "custom-value"
)
}
}
context("With empty custom headers") {
should("return the base headers unchanged") {
TranslationConfig.processTranslationMetadata(
RoomMetadata.Metadata.Translation(httpHeaders = emptyMap()),
baseHeaders
) shouldBe baseHeaders
}
}
}
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.jitsi.config.withLegacyConfig
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config
import org.jitsi.metaconfig.MetaconfigSettings
import org.jitsi.utils.mins
import org.jitsi.utils.secs
class BridgeConfigTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
init {
MetaconfigSettings.cacheEnabled = false
context("with no config the defaults from reference.conf should be used") {
config.maxBridgeParticipants shouldBe 80
// The rate limit is disabled by default.
config.maxBridgeParticipantsPerInterval shouldBe -1
config.maxBridgeParticipantsInterval shouldBe 1.mins
}
context("max-bridge-participants-per-interval") {
withNewConfig(
"""
jicofo.bridge.max-bridge-participants-per-interval=30
jicofo.bridge.max-bridge-participants-interval=30 seconds
""".trimIndent()
) {
config.maxBridgeParticipantsPerInterval shouldBe 30
config.maxBridgeParticipantsInterval shouldBe 30.secs
}
}
context("with legacy config") {
withLegacyConfig(legacyConfig) {
config.maxBridgeParticipants shouldBe 111
}
}
context("with new config") {
withNewConfig(newConfig) {
config.maxBridgeParticipants shouldBe 222
}
}
context("with both legacy and new config the legacy values should be used") {
withLegacyConfig(legacyConfig) {
withNewConfig(newConfig) {
config.maxBridgeParticipants shouldBe 111
}
}
}
context("Selection strategy") {
context("With default config") {
config.selectionStrategy.shouldBeInstanceOf<SingleBridgeSelectionStrategy>()
}
context("With legacy config") {
withLegacyConfig(
"org.jitsi.jicofo.BridgeSelector.BRIDGE_SELECTION_STRATEGY=SplitBridgeSelectionStrategy"
) {
config.selectionStrategy.shouldBeInstanceOf<SplitBridgeSelectionStrategy>()
}
}
context("With new config") {
withNewConfig("jicofo.bridge.selection-strategy=RegionBasedBridgeSelectionStrategy") {
config.selectionStrategy.shouldBeInstanceOf<RegionBasedBridgeSelectionStrategy>()
}
}
}
context("Health checks") {
withNewConfig("jicofo.bridge.health-checks.interval = 2 minutes".trimIndent()) {
config.healthChecksInterval shouldBe 2.mins
config.healthChecksRetryDelay shouldBe 1.mins
}
}
context("Region groups") {
config.regionGroups shouldBe emptyMap()
withNewConfig(
"""
jicofo.bridge.region-groups = [
[ "us-east", "us-west" ],
[ "eu-central", "eu-west"]
]
""".trimIndent()
) {
config.regionGroups shouldBe mapOf(
"us-east" to setOf("us-east", "us-west"),
"us-west" to setOf("us-east", "us-west"),
"eu-central" to setOf("eu-central", "eu-west"),
"eu-west" to setOf("eu-central", "eu-west")
)
config.getRegionGroup(null) shouldBe emptySet()
config.getRegionGroup("abc") shouldBe setOf("abc")
config.getRegionGroup("us-east") shouldBe setOf("us-east", "us-west")
}
}
}
}
private val legacyConfig = """
org.jitsi.jicofo.BridgeSelector.MAX_PARTICIPANTS_PER_BRIDGE=111
org.jitsi.jicofo.BridgeSelector.MAX_BRIDGE_PACKET_RATE=111
org.jitsi.jicofo.BridgeSelector.AVG_PARTICIPANT_PACKET_RATE=111
""".trimIndent()
private val newConfig = """
jicofo {
bridge {
max-bridge-participants=222
average-participant-packet-rate-pps=222
}
}
""".trimIndent()

View File

@@ -0,0 +1,218 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.Spec
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.setNewConfig
import org.jitsi.utils.time.FakeClock
import org.jxmpp.jid.impl.JidCreate
/**
* This test simulates the intended procedure to upgrade the set of bridges connected to a jicofo instance, and
* verifies the selection logic.
*/
class BridgeReleaseTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.SingleInstance
val clock = FakeClock()
override suspend fun afterSpec(spec: Spec) = super.afterSpec(spec).also {
setNewConfig("", true)
}
init {
context("Test") {
// This config is required for the whole test. Set it here instead of in [beforeSpec] because this executes
// earlier.
setNewConfig(
"""
$regionBasedConfig
jicofo.bridge.max-bridge-participants=$MAX_BP
""".trimIndent(),
true
)
BridgeConfig.config.maxBridgeParticipants shouldBe MAX_BP
val selector = BridgeSelector(clock)
// We start with bridges from a single "old" release.
val old1 = selector.createBridge("old1", OLD_VERSION, 0.1)
val old2 = selector.createBridge("old2", OLD_VERSION, 0.2)
val old3 = selector.createBridge("old3", OLD_VERSION, 0.3)
// Initially we only have bridges from the "old" release. Verify the basics.
// Select the least loaded bridge
selector.testSelect() shouldBe old1
selector.testSelect(version = OLD_VERSION) shouldBe old1
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1))) shouldBe old1
// Select an existing conference bridge
selector.testSelect(mapOf(old2 to ConferenceBridgeProperties(1))) shouldBe old2
selector.testSelect(mapOf(old3 to ConferenceBridgeProperties(1))) shouldBe old3
selector.testSelect(
mapOf(
old2 to ConferenceBridgeProperties(2),
old3 to ConferenceBridgeProperties(1)
)
) shouldBe old2
// Fail if the version doesn't match
selector.testSelect(version = "invalid-version") shouldBe null
// Fail with inconsistent version pinning
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe null
// Honor max-participants-per-bridge
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(MAX_BP))) shouldBe old2
selector.testSelect(
mapOf(
old1 to ConferenceBridgeProperties(MAX_BP),
old3 to ConferenceBridgeProperties(1)
)
) shouldBe old3
selector.testSelect(
mapOf(
old1 to ConferenceBridgeProperties(MAX_BP),
old3 to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe old2
// Select the least loaded if all are full
selector.testSelect(
mapOf(
old1 to ConferenceBridgeProperties(MAX_BP),
old2 to ConferenceBridgeProperties(MAX_BP),
old3 to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe old1
// We add a new bridge with a new release.
val new1 = selector.createBridge("new1", NEW_VERSION, 0.0, drain = true)
// An old one should be used even though the new one has stress 0, because the new one is drained.
selector.testSelect() shouldBe old1
selector.testSelect(version = NEW_VERSION) shouldBe new1
selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1))) shouldBe new1
new1.setStats(stress = 0.3, drain = true)
// And more bridges with a new release.
val new2 = selector.createBridge("new2", NEW_VERSION, 0.2, drain = true)
val new3 = selector.createBridge("new3", NEW_VERSION, 0.1, drain = true)
selector.testSelect(version = NEW_VERSION) shouldBe new3
selector.testSelect(mapOf(new2 to ConferenceBridgeProperties(1))) shouldBe new2
selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1))) shouldBe new1
selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe new1
selector.testSelect(mapOf(new2 to ConferenceBridgeProperties(1))) shouldBe new2
selector.testSelect(mapOf(new2 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe new2
selector.testSelect(mapOf(new3 to ConferenceBridgeProperties(1))) shouldBe new3
selector.testSelect(mapOf(new3 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe new3
selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1)), version = OLD_VERSION) shouldBe null
// Honor max-participants-per-bridge, even though all new bridges are in drain.
selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(MAX_BP))) shouldBe new3
selector.testSelect(
mapOf(
new1 to ConferenceBridgeProperties(MAX_BP),
new2 to ConferenceBridgeProperties(1)
)
) shouldBe new2
selector.testSelect(
mapOf(
new1 to ConferenceBridgeProperties(MAX_BP),
new2 to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe new3
selector.testSelect(
mapOf(
new1 to ConferenceBridgeProperties(MAX_BP),
new2 to ConferenceBridgeProperties(MAX_BP),
new3 to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe new3
// Everything should work the same unless a conference is pinned to the new version.
// Select the least loaded bridge
selector.testSelect() shouldBe old1
selector.testSelect(version = OLD_VERSION) shouldBe old1
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1))) shouldBe old1
// Select an existing conference bridge
selector.testSelect(mapOf(old2 to ConferenceBridgeProperties(1))) shouldBe old2
selector.testSelect(mapOf(old3 to ConferenceBridgeProperties(1))) shouldBe old3
selector.testSelect(
mapOf(
old2 to ConferenceBridgeProperties(2),
old3 to ConferenceBridgeProperties(1)
)
) shouldBe old2
// Fail if the version doesn't match
selector.testSelect(version = "invalid-version") shouldBe null
// Fail with inconsistent version pinning
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe null
// Honor max-participants-per-bridge
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(MAX_BP))) shouldBe old2
selector.testSelect(
mapOf(
old1 to ConferenceBridgeProperties(MAX_BP),
old3 to ConferenceBridgeProperties(1)
)
) shouldBe old3
selector.testSelect(
mapOf(
old1 to ConferenceBridgeProperties(MAX_BP),
old3 to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe old2
selector.testSelect(
mapOf(
old1 to ConferenceBridgeProperties(MAX_BP),
old2 to ConferenceBridgeProperties(MAX_BP),
old3 to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe old1
// Switch the releases
setOf(old1, old2, old3).forEach { it.setStats(drain = true) }
setOf(new1, new2, new3).forEach { it.setStats(drain = false) }
// Select the new version for new conferences
old1.setStats(stress = 0.0, drain = true)
// old1 should not be selected because it is in drain
selector.testSelect() shouldBe new3
selector.testSelect(mapOf(new3 to ConferenceBridgeProperties(MAX_BP))) shouldBe new2
// Select the old version for existing conferences or pinned
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1))) shouldBe old1
// Should select old2 even though all old bridges are in drain.
selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(MAX_BP))) shouldBe old2
selector.testSelect(version = OLD_VERSION) shouldBe old1
}
}
}
private fun BridgeSelector.createBridge(jid: String, version: String, stress: Double, drain: Boolean = false) =
addJvbAddress(JidCreate.from(jid)).apply {
setStats(
version = version,
stress = stress,
drain = drain,
region = "region",
relayId = jid
)
}
private const val MAX_BP = 80
private const val OLD_VERSION = "old"
private const val NEW_VERSION = "new"
private fun BridgeSelector.testSelect(
conferenceBridges: Map<Bridge, ConferenceBridgeProperties> = emptyMap(),
version: String? = null
) = selectBridge(conferenceBridges, ParticipantProperties(region = "region"), version = version)

View File

@@ -0,0 +1,176 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withNewConfig
import org.jxmpp.jid.impl.JidCreate
class BridgeSelectionStrategyTest : ShouldSpec() {
init {
val localRegion = "local-region"
val strategy: RegionBasedBridgeSelectionStrategy = createWithNewConfig("jicofo.local-region=$localRegion") {
RegionBasedBridgeSelectionStrategy()
}
context("testRegionBasedSelection") {
val region1 = "region1"
val region2 = localRegion
val region3 = "region3"
val bridge1 = Bridge(JidCreate.from("bridge1")).apply { setStats(region = region1) }
val bridge2 = Bridge(JidCreate.from("bridge2")).apply { setStats(region = region2) }
val bridge3 = Bridge(JidCreate.from("bridge3")).apply { setStats(region = region3) }
val localBridge = bridge2
val props1 = ParticipantProperties(region1)
val props2 = ParticipantProperties(region2)
val props3 = ParticipantProperties(region3)
val propsInvalid = ParticipantProperties("invalid region")
val propsNull = ParticipantProperties(null)
val allBridges = listOf(bridge1, bridge2, bridge3)
val conferenceBridges: MutableMap<Bridge, ConferenceBridgeProperties> = HashMap()
// Initial selection should select a bridge in the participant's region/ if possible
strategy.select(allBridges, conferenceBridges, props1, true) shouldBe bridge1
strategy.select(allBridges, conferenceBridges, props2, true) shouldBe bridge2
// Or a bridge in the local region otherwise
strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe localBridge
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe localBridge
conferenceBridges[bridge3] = ConferenceBridgeProperties(1)
strategy.select(allBridges, conferenceBridges, props3, true) shouldBe bridge3
strategy.select(allBridges, conferenceBridges, props2, true) shouldBe bridge2
// A participant in an unknown region should be allocated on a local bridge.
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe localBridge
conferenceBridges[bridge2] = ConferenceBridgeProperties(1)
// A participant in an unknown region should be allocated on the least loaded (according to the order of
// 'allBridges') existing conference bridge.
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe bridge2
// A participant in a region with no bridges should also be allocated on the least loaded (according to the
// order of 'allBridges') existing conference bridge.
strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe bridge2
}
context("Port of BridgeSelectionStrategyTest.java#preferLowestStress") {
val lowStressRegion = "lowStressRegion"
val mediumStressRegion = "mediumStressRegion"
val highStressRegion = "highStressRegion"
val lowStressBridge = createBridge(lowStressRegion, 0.1)
val mediumStressBridge = createBridge(mediumStressRegion, 0.3)
val highStressBridge = createBridge(highStressRegion, 0.8)
val allBridges = listOf(lowStressBridge, mediumStressBridge, highStressBridge)
val lowStressProps = ParticipantProperties(lowStressRegion)
val mediumStressProps = ParticipantProperties(mediumStressRegion)
val highStressProps = ParticipantProperties(highStressRegion)
val propsInvalid = ParticipantProperties("invalid region")
val propsNull = ParticipantProperties(null)
val conferenceBridges = mutableMapOf<Bridge, ConferenceBridgeProperties>()
// Initial selection should select a non-overloaded bridge in the participant's region if possible. If not,
// it should select the lowest loaded bridge.
strategy.select(allBridges, conferenceBridges, highStressProps, true) shouldBe lowStressBridge
strategy.select(allBridges, conferenceBridges, mediumStressProps, true) shouldBe mediumStressBridge
strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe lowStressBridge
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe lowStressBridge
// Now assume that the low-stressed bridge is in the conference.
conferenceBridges[lowStressBridge] = ConferenceBridgeProperties(1)
strategy.select(allBridges, conferenceBridges, lowStressProps, true) shouldBe lowStressBridge
strategy.select(allBridges, conferenceBridges, mediumStressProps, true) shouldBe mediumStressBridge
// A participant in an unknown region should be allocated on the
// existing conference bridge.
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe lowStressBridge
// Now assume that a medium-stressed bridge is also in the conference.
conferenceBridges[mediumStressBridge] = ConferenceBridgeProperties(1)
// A participant in an unknown region should be allocated on the least
// loaded (according to the order of 'allBridges') existing conference
// bridge.
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe lowStressBridge
// A participant in a region with no bridges should also be allocated
// on the least loaded (according to the order of 'allBridges') existing
// conference bridge.
strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe lowStressBridge
}
context("Port of BridgeSelectionStrategyTest.java#preferRegionWhenStressIsEqual") {
// Here we specify 3 bridges in 3 different regions: one high-stressed and two medium-stressed.
val mediumStressRegion1 = "mediumStressRegion1"
val mediumStressRegion2 = "mediumStressRegion2"
val highStressRegion = "highStressRegion"
val mediumStressBridge1 = createBridge(mediumStressRegion1, 0.25)
val mediumStressBridge2 = createBridge(mediumStressRegion2, 0.3)
val highStressBridge = createBridge(highStressRegion, 0.8)
val allBridges = listOf(mediumStressBridge1, mediumStressBridge2, highStressBridge)
val mediumStressProps1 = ParticipantProperties(mediumStressRegion1)
val mediumStressProps2 = ParticipantProperties(mediumStressRegion2)
val highStressProps = ParticipantProperties(highStressRegion)
val propsInvalid = ParticipantProperties("invalid region")
val propsNull = ParticipantProperties(null)
val conferenceBridges = mutableMapOf<Bridge, ConferenceBridgeProperties>()
// Initial selection should select a non-overloaded bridge in the participant's region if possible. If not,
// it should select the lowest loaded bridge.
strategy.select(allBridges, conferenceBridges, highStressProps, true) shouldBe mediumStressBridge1
strategy.select(allBridges, conferenceBridges, mediumStressProps2, true) shouldBe mediumStressBridge2
strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe mediumStressBridge1
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe mediumStressBridge1
conferenceBridges[mediumStressBridge2] = ConferenceBridgeProperties(1)
strategy.select(allBridges, conferenceBridges, mediumStressProps1, true) shouldBe mediumStressBridge1
strategy.select(allBridges, conferenceBridges, mediumStressProps2, true) shouldBe mediumStressBridge2
// A participant in an unknown region should be allocated on the existing conference bridge.
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe mediumStressBridge2
// Now assume that a high-stressed bridge is in the conference.
conferenceBridges[highStressBridge] = ConferenceBridgeProperties(1)
// A participant in an unknown region should be allocated on the least
// loaded (according to the order of 'allBridges') existing conference
// bridge.
strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe mediumStressBridge2
// A participant in a region with no bridges should also be allocated
// on the least loaded (according to the order of 'allBridges') existing
// conference bridge.
strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe mediumStressBridge2
}
}
}
private fun createBridge(region: String, stress: Double) = Bridge(JidCreate.from(region)).apply {
setStats(stress = stress, region = region)
}
private fun <T : Any> createWithNewConfig(config: String, block: () -> T): T {
lateinit var ret: T
withNewConfig(config) {
ret = block()
}
return ret
}

View File

@@ -0,0 +1,289 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.collections.shouldBeIn
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.util.context
import org.jitsi.metaconfig.MetaconfigSettings
import org.jitsi.utils.ms
import org.jitsi.utils.time.FakeClock
import org.jxmpp.jid.impl.JidCreate
class BridgeSelectorTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
init {
val clock = FakeClock()
// Test different types of jid (domain, entity bare, entity full).
val jid1 = JidCreate.from("jvb1.example.com")
val jid2 = JidCreate.from("jvb2@example.com")
val jid3 = JidCreate.from("jvb3@example.com/goldengate")
context("Stress from new endpoints") {
val bridgeSelector = BridgeSelector(clock)
val bridge = bridgeSelector.addJvbAddress(jid1).apply { setStats() }
bridge.correctedStress shouldBe 0
bridgeSelector.selectBridge()
bridge.endpointAdded()
// The stress should increase because it was recently selected.
bridge.correctedStress shouldNotBe 0
}
context("Selection based on operational status") {
val bridgeSelector = BridgeSelector(clock)
val jvb1 = bridgeSelector.addJvbAddress(jid1).apply { setStats() }
val jvb2 = bridgeSelector.addJvbAddress(jid2).apply { setStats() }
val jvb3 = bridgeSelector.addJvbAddress(jid3).apply { setStats() }
bridgeSelector.selectBridge() shouldBeIn setOf(jvb1, jvb2, jvb3)
// Bridge 1 is down
jvb1.isOperational = false
bridgeSelector.selectBridge() shouldBeIn setOf(jvb2, jvb3)
// Bridge 2 is down
jvb2.isOperational = false
bridgeSelector.selectBridge() shouldBe jvb3
// Bridge 1 is up again, but 3 is down instead
jvb1.isOperational = true
jvb3.isOperational = false
// We need to elapse time after setting isOperational=true because isOperational=false is sticky
clock.elapse(BridgeConfig.config.failureResetThreshold)
bridgeSelector.selectBridge() shouldBe jvb1
}
context("Selection based on stress level") {
val bridgeSelector = BridgeSelector(clock)
val jvb1 = bridgeSelector.addJvbAddress(jid1).apply { setStats(stress = 0.1) }
val jvb2 = bridgeSelector.addJvbAddress(jid2).apply { setStats(stress = 0.23) }
val jvb3 = bridgeSelector.addJvbAddress(jid3).apply { setStats(stress = 0.0) }
bridgeSelector.selectBridge() shouldBe jvb3
// Now Jvb 3 gets occupied the most
jvb3.setStats(stress = 0.3)
bridgeSelector.selectBridge() shouldBe jvb1
// Jvb 1 is gone
jvb1.isOperational = false
bridgeSelector.selectBridge() shouldBe jvb2
// All bridges down
jvb2.isOperational = false
jvb3.isOperational = false
bridgeSelector.selectBridge() shouldBe null
jvb1.isOperational = true
jvb2.isOperational = true
jvb3.isOperational = true
// We need to elapse time after setting isOperational=true because isOperational=false is sticky
clock.elapse(BridgeConfig.config.failureResetThreshold)
jvb1.setStats(stress = .01)
jvb2.setStats(stress = 0.0)
jvb3.setStats(stress = 0.0)
bridgeSelector.selectBridge() shouldBeIn setOf(jvb2, jvb3)
// JVB 2 least occupied
jvb1.setStats(stress = .01)
jvb2.setStats(stress = 0.0)
jvb3.setStats(stress = .01)
// Reset recently added endpoints (100ms needed because of the bucket size).
clock.elapse(BridgeConfig.config.participantRampupInterval + 100.ms)
bridgeSelector.selectBridge() shouldBe jvb2
}
context(config = regionBasedConfig, name = "Mixing versions") {
val bridgeSelector = BridgeSelector(clock)
val jvb1 = bridgeSelector.addJvbAddress(jid1).apply { setStats(version = "v1", stress = 0.9, region = "r") }
val jvb2 = bridgeSelector.addJvbAddress(jid2).apply { setStats(version = "v2", stress = 0.1, region = "r") }
context("With explicit API call") {
bridgeSelector.selectBridge(version = "v1") shouldBe jvb1
bridgeSelector.selectBridge(version = "v2") shouldBe jvb2
bridgeSelector.selectBridge(version = "v-nonexistent") shouldBe null
}
context("From an existing conference bridge") {
bridgeSelector.selectBridge(
conferenceBridges = mapOf(jvb1 to ConferenceBridgeProperties(1))
) shouldBe jvb1
val jvb3 = bridgeSelector.addJvbAddress(jid3).apply {
setStats(version = "v1", stress = 0.1, region = "r")
}
bridgeSelector.selectBridge(
conferenceBridges = mapOf(
jvb1 to ConferenceBridgeProperties(1)
)
) shouldBe jvb3
}
}
context(config = regionBasedConfig, name = "Selection with a conference bridge removed from the selector") {
val regionBasedSelector = BridgeSelector(clock)
val jvb1 = regionBasedSelector.addJvbAddress(jid1).apply { setStats(stress = 0.2, region = "r1") }
val jvb2 = regionBasedSelector.addJvbAddress(jid2).apply { setStats(stress = 0.5, region = "r2") }
val jvb3 = regionBasedSelector.addJvbAddress(jid3).apply { setStats(stress = 0.1, region = "r3") }
regionBasedSelector.removeJvbAddress(jid3)
regionBasedSelector.selectBridge(
mapOf(
jvb1 to ConferenceBridgeProperties(1),
jvb2 to ConferenceBridgeProperties(1),
jvb3 to ConferenceBridgeProperties(1)
),
ParticipantProperties()
) shouldBe jvb1
regionBasedSelector.selectBridge(
mapOf(
jvb1 to ConferenceBridgeProperties(1),
jvb2 to ConferenceBridgeProperties(1),
jvb3 to ConferenceBridgeProperties(1)
),
ParticipantProperties("r2")
) shouldBe jvb2
}
context(config = splitConfig, name = "SplitBridgeSelectionStrategy") {
val splitSelector = BridgeSelector(clock)
val jvb1 = splitSelector.addJvbAddress(jid1).apply { setStats(stress = 0.2, region = "r1") }
val jvb2 = splitSelector.addJvbAddress(jid2).apply { setStats(stress = 0.5, region = "r2") }
val jvb3 = splitSelector.addJvbAddress(jid3).apply { setStats(stress = 0.1, region = "r3") }
splitSelector.selectBridge() shouldBeIn setOf(jvb1, jvb2, jvb3)
splitSelector.selectBridge(
mapOf(
jvb1 to ConferenceBridgeProperties(1)
),
ParticipantProperties()
) shouldBeIn setOf(jvb2, jvb3)
splitSelector.selectBridge(
mapOf(
jvb1 to ConferenceBridgeProperties(1),
jvb2 to ConferenceBridgeProperties(1)
),
ParticipantProperties()
) shouldBe jvb3
splitSelector.selectBridge(
mapOf(
jvb1 to ConferenceBridgeProperties(1),
jvb2 to ConferenceBridgeProperties(2),
jvb3 to ConferenceBridgeProperties(3)
),
ParticipantProperties()
) shouldBe jvb1
splitSelector.removeJvbAddress(jid1)
splitSelector.selectBridge(
mapOf(
jvb1 to ConferenceBridgeProperties(1),
jvb2 to ConferenceBridgeProperties(2),
jvb3 to ConferenceBridgeProperties(3)
),
ParticipantProperties()
) shouldBe jvb2
}
context(config = visitorConfig, name = "VisitorSelectionStrategy") {
val visitorSelector = BridgeSelector(clock)
val jvb1 = visitorSelector.addJvbAddress(jid1).apply { setStats(stress = 0.2, region = "r1") }
val jvb2 = visitorSelector.addJvbAddress(jid2).apply { setStats(stress = 0.2, region = "r1") }
val jvb3 = visitorSelector.addJvbAddress(jid3).apply { setStats(stress = 0.1, region = "r1") }
val participantBridge = visitorSelector.selectBridge(
mapOf(),
ParticipantProperties(visitor = false)
)
participantBridge shouldBeIn setOf(jvb1, jvb2, jvb3)
val visitorBridge = visitorSelector.selectBridge(
mapOf(
participantBridge!! to ConferenceBridgeProperties(1)
),
ParticipantProperties(visitor = true)
)
visitorBridge shouldNotBe participantBridge
}
context("Lost bridges stats") {
val selector = BridgeSelector(clock)
// TODO use MetricsContainer.reset() instead
val initialLostBridges = BridgeSelector.lostBridges.get()
// BridgeSelector.lostBridges.get() shouldBe initialLostBridges
val jvb1 = selector.addJvbAddress(jid1)
BridgeSelector.lostBridges.get() shouldBe initialLostBridges
should("Increment the lost bridges stat when a bridge goes away") {
selector.removeJvbAddress(jvb1.jid)
BridgeSelector.lostBridges.get() shouldBe initialLostBridges + 1
}
should("Not increment the lost bridges stat when a bridge in graceful-shutdown goes away") {
jvb1.setStats(gracefulShutdown = true)
selector.removeJvbAddress(jvb1.jid)
BridgeSelector.lostBridges.get() shouldBe initialLostBridges
}
}
xcontext("Performance") {
withNewConfig(regionGroupsConfig) {
// Config caching has a huge impact!
MetaconfigSettings.cacheEnabled = true
val numBridges = 500
val times = 1000
val selector = BridgeSelector(clock)
for (i in 1..numBridges) {
selector.addJvbAddress(JidCreate.from("jvb-$i")).apply {
// Force the worst-case in RegionBasedBridgeSelectionStrategy (the last "least loaded" branch).
setStats(stress = 0.99, region = "bridge-region")
}
}
repeat(10) {
val start = System.currentTimeMillis()
for (i in 0..times) {
selector.selectBridge(
participantProperties = ParticipantProperties("participant-region-no-match")
)
}
val end = System.currentTimeMillis()
val avgNs = (end - start) * 1_000_000.toDouble() / times
println("Took ${end - start} ms to select $times times (an average of $avgNs ns per selection)")
}
}
}
}
}
private const val ENABLE_OCTO_CONFIG = "jicofo.octo.enabled=true"
val regionBasedConfig = """
$ENABLE_OCTO_CONFIG
jicofo.bridge.selection-strategy=RegionBasedBridgeSelectionStrategy
""".trimIndent()
private val splitConfig = """
$ENABLE_OCTO_CONFIG
jicofo.bridge.selection-strategy=SplitBridgeSelectionStrategy
""".trimIndent()
private val visitorConfig = """
$ENABLE_OCTO_CONFIG
jicofo.bridge.selection-strategy=VisitorSelectionStrategy
jicofo.bridge.visitor-selection-strategy=SingleBridgeSelectionStrategy
jicofo.bridge.participant-selection-strategy=SingleBridgeSelectionStrategy
""".trimIndent()

View File

@@ -0,0 +1,147 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.ints.shouldBeLessThan
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.jitsi.utils.time.FakeClock
import org.jitsi.utils.times
import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension
import org.jxmpp.jid.impl.JidCreate
class BridgeTest : ShouldSpec({
context("when comparing two bridges") {
should("the bridge that is operational should have higher priority (should compare lower)") {
val bridge1: Bridge = mockk {
every { isOperational } returns true
every { isInGracefulShutdown } returns false
every { correctedStress } returns 10.0
}
val bridge2: Bridge = mockk {
every { isOperational } returns false
every { isInGracefulShutdown } returns true
every { correctedStress } returns .1
}
Bridge.compare(bridge1, bridge2) shouldBeLessThan 0
}
should("the bridge that is in graceful shutdown mode should have lower priority (should compare higher)") {
val bridge1: Bridge = mockk {
every { isOperational } returns true
every { isInGracefulShutdown } returns false
every { correctedStress } returns 10.0
}
val bridge2: Bridge = mockk {
every { isOperational } returns true
every { isInGracefulShutdown } returns true
every { correctedStress } returns .1
}
Bridge.compare(bridge1, bridge2) shouldBeLessThan 0
}
should("the bridge that is stressed should have lower priority (should compare higher)") {
val bridge1: Bridge = mockk {
every { isOperational } returns true
every { isInGracefulShutdown } returns false
every { correctedStress } returns 10.0
}
val bridge2: Bridge = mockk {
every { isOperational } returns true
every { isInGracefulShutdown } returns false
every { correctedStress } returns .1
}
Bridge.compare(bridge2, bridge1) shouldBeLessThan 0
}
}
context("notOperationalThresholdTest") {
val clock = FakeClock()
val bridge = Bridge(JidCreate.from("bridge"), clock)
val failureResetThreshold = BridgeConfig.config.failureResetThreshold
bridge.isOperational shouldBe true
bridge.isOperational = false
bridge.isOperational shouldBe false
clock.elapse(failureResetThreshold.times(100))
bridge.isOperational shouldBe false
bridge.isOperational = true
bridge.isOperational shouldBe true
bridge.isOperational = false
bridge.isOperational shouldBe false
clock.elapse(failureResetThreshold.dividedBy(2))
bridge.isOperational shouldBe false
bridge.isOperational = true
bridge.isOperational shouldBe false
clock.elapse(failureResetThreshold)
bridge.isOperational shouldBe true
}
context("Setting stats") {
// This mostly makes sure the test framework works as expected.
val bridge = Bridge(JidCreate.from("bridge"))
bridge.correctedStress shouldBe 0
bridge.region shouldBe null
bridge.setStats(stress = 0.1)
bridge.correctedStress shouldBe 0.1
bridge.region shouldBe null
// The different stats should be updated independently.
bridge.setStats(region = "region")
bridge.correctedStress shouldBe 0.1
bridge.region shouldBe "region"
// The different stats should be updated independently.
bridge.setStats(stress = 0.2)
bridge.correctedStress shouldBe 0.2
bridge.region shouldBe "region"
}
})
fun Bridge.setStats(
stress: Double? = null,
region: String? = null,
relayId: String? = region,
version: String? = null,
colibri2: Boolean = true,
gracefulShutdown: Boolean = false,
drain: Boolean = false
) = setStats(
ColibriStatsExtension().apply {
stress?.let { addStat(ColibriStatsExtension.Stat("stress_level", it)) }
region?.let {
addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.REGION, it))
}
relayId?.let {
addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.RELAY_ID, it))
}
version?.let { addStat(ColibriStatsExtension.Stat("version", version)) }
if (colibri2) addStat("colibri2", "true")
if (gracefulShutdown) addStat(ColibriStatsExtension.SHUTDOWN_IN_PROGRESS, "true")
addStat(ColibriStatsExtension.DRAIN, if (drain) "true" else "false")
}
)

View File

@@ -0,0 +1,414 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
class TestCascade : Cascade<TestCascadeNode, TestCascadeLink> {
override val sessions = HashMap<String?, TestCascadeNode>()
var linksRemoved = 0
override fun addLinkBetween(session: TestCascadeNode, otherSession: TestCascadeNode, meshId: String) {
require(!session.relays.contains(otherSession.relayId)) {
"$this already has a link to $otherSession"
}
require(!otherSession.relays.contains(session.relayId)) {
"$otherSession already has a link to $this"
}
session.addLink(otherSession, meshId)
otherSession.addLink(session, meshId)
}
override fun removeLinkTo(session: TestCascadeNode, otherSession: TestCascadeNode) {
linksRemoved++
}
}
class TestCascadeNode(override val relayId: String) : CascadeNode<TestCascadeNode, TestCascadeLink> {
override val relays = HashMap<String, TestCascadeLink>()
internal fun addLink(node: TestCascadeNode, meshId: String) {
relays[node.relayId] = TestCascadeLink(node.relayId, meshId)
}
override fun toString(): String = "$relayId: [${relays.values.joinToString()}]"
}
class TestCascadeLink(override val relayId: String, override val meshId: String?) : CascadeLink {
override fun toString(): String = "$meshId->$relayId"
}
class CascadeTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private val numNodes = 5
init {
context("creating a cascade with a single mesh") {
val cascade = TestCascade()
val nodes = Array(numNodes) { i -> TestCascadeNode(i.toString()) }
nodes.iterator().forEach { cascade.addNodeToMesh(it, "A") }
should("cause all the nodes to be connected") {
nodes.forEach { node ->
cascade.containsNode(node) shouldBe true
node.relays.size shouldBe numNodes - 1
nodes.forEach { other ->
if (other === node) {
node.relays.contains(other.relayId) shouldBe false
} else {
node.relays[other.relayId]?.relayId shouldBe other.relayId
}
}
}
}
should("validate") {
cascade.validate()
}
should("enumerate only one node behind each link") {
nodes.forEach { node ->
nodes.forEach { other ->
if (node != other) {
cascade.getNodesBehind(node, other).shouldContainExactlyInAnyOrder(other)
}
}
cascade.getNodesBehind("A", node).shouldContainExactlyInAnyOrder(node)
}
}
should("have paths directly from each node") {
nodes.forEach { node ->
var pathsVisited = 0
cascade.getPathsFrom(node) { c, n, from ->
c shouldBe cascade
if (n == node) {
from shouldBe null
} else {
from shouldBe node
}
pathsVisited++
}
pathsVisited shouldBe numNodes
}
}
should("have the correct distance from each node to another") {
nodes.forEach { first ->
nodes.forEach { other ->
cascade.getDistanceFrom(first) { it == other } shouldBe if (first == other) 0 else 1
}
}
cascade.getDistanceFrom(nodes[1]) { false } shouldBe Int.MAX_VALUE
}
should("not call a callback when removing any node") {
nodes.forEach {
var called = false
cascade.removeNode(it) { _, _ ->
called = true
setOf()
}
called shouldBe false
}
cascade.validate()
cascade.linksRemoved shouldBe 4 + 3 + 2 + 1
}
}
context("creating a cascade with two meshes") {
val cascade = TestCascade()
val nodes = Array(numNodes) { i -> TestCascadeNode(i.toString()) }
cascade.addNodeToMesh(nodes[0], "A")
cascade.addNodeToMesh(nodes[1], "A")
cascade.addNodeToMesh(nodes[2], "A")
cascade.addNodeToMesh(nodes[3], "B", nodes[0])
cascade.addNodeToMesh(nodes[4], "B")
should("create the correct links for each node") {
val aNodes = cascade.getMeshNodes("A")
val bNodes = cascade.getMeshNodes("B")
aNodes.map { it.relayId }.shouldContainExactly("0", "1", "2")
bNodes.map { it.relayId }.shouldContainExactly("0", "3", "4")
nodes[0].relays.size shouldBe 4
for (i in 1 until numNodes) {
nodes[i].relays.size shouldBe 2
}
arrayOf(aNodes, bNodes).forEach { set ->
set.forEach { node ->
set.forEach { other ->
if (other === node) {
node.relays.contains(other.relayId) shouldBe false
} else {
node.relays[other.relayId]?.relayId shouldBe other.relayId
}
}
}
}
}
should("validate") {
cascade.validate()
}
should("enumerate only one node behind each leaf node from the core node") {
for (i in 1 until numNodes) {
cascade.getNodesBehind(nodes[0], nodes[i]).shouldContainExactlyInAnyOrder(nodes[i])
}
}
should("enumerate the nodes of the far mesh behind the core node from each leaf node") {
cascade.getNodesBehind(nodes[1], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[3], nodes[4])
cascade.getNodesBehind(nodes[2], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[3], nodes[4])
cascade.getNodesBehind(nodes[3], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2])
cascade.getNodesBehind(nodes[4], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2])
cascade.getNodesBehind("A", nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[3], nodes[4])
cascade.getNodesBehind("B", nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2])
}
should("have paths directly from the core node") {
var pathsVisited = 0
cascade.getPathsFrom(nodes[0]) { c, n, from ->
c shouldBe cascade
if (n == nodes[0]) {
from shouldBe null
} else {
from shouldBe nodes[0]
}
pathsVisited++
}
pathsVisited shouldBe numNodes
}
should("have non-local paths from other nodes through the core node") {
for (i in 1 until numNodes) {
val locals = when (i) {
1, 2 -> setOf(nodes[0], nodes[1], nodes[2])
3, 4 -> setOf(nodes[0], nodes[3], nodes[4])
else -> setOf()
}
var pathsVisited = 0
cascade.getPathsFrom(nodes[i]) { c, n, from ->
c shouldBe cascade
when {
n == nodes[i] -> from shouldBe null
locals.contains(n) -> from shouldBe nodes[i]
else -> from shouldBe nodes[0]
}
pathsVisited++
}
pathsVisited shouldBe numNodes
}
}
should("have the correct distance from each node to another") {
for (i in 1 until numNodes) {
cascade.getDistanceFrom(nodes[i]) { it == nodes[0] } shouldBe 1
cascade.getDistanceFrom(nodes[0]) { it == nodes[i] } shouldBe 1
}
// Just spot-check these rather than enumerating all of them
cascade.getDistanceFrom(nodes[1]) { it == nodes[2] } shouldBe 1
cascade.getDistanceFrom(nodes[1]) { it == nodes[3] } shouldBe 2
cascade.getDistanceFrom(nodes[1]) { false } shouldBe Int.MAX_VALUE
}
should("not call a callback when removing a leaf node") {
for (i in 1 until numNodes) {
var called = false
cascade.removeNode(nodes[i]) { _, _ ->
called = true
setOf()
}
called shouldBe false
}
cascade.validate()
cascade.linksRemoved shouldBe 3 + 2 + 1
}
should("call the callback when removing the core node") {
var called = true
cascade.removeNode(nodes[0]) { _, disconnectedMeshes ->
called = true
disconnectedMeshes.size shouldBe 2
disconnectedMeshes.shouldContainExactlyInAnyOrder(
setOf(nodes[1], nodes[2]),
setOf(nodes[3], nodes[4])
)
setOf()
}
called shouldBe true
}
should("fail validation if not repaired after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ -> setOf() }
shouldThrow<IllegalStateException> {
cascade.validate()
}
}
should("validate if repaired correctly (1) after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ ->
setOf(CascadeRepair(nodes[1], nodes[3], "C"))
}
cascade.validate()
}
should("validate if repaired correctly (2) after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ ->
setOf(
CascadeRepair(nodes[1], nodes[3], "A"),
CascadeRepair(nodes[2], nodes[3], "A")
)
}
cascade.validate()
}
should("not validate if repaired incorrectly (1) after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ ->
setOf(CascadeRepair(nodes[1], nodes[3], "A"))
}
shouldThrow<IllegalStateException> {
cascade.validate()
}
}
should("not validate if repaired incorrectly (2) after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ ->
setOf(
CascadeRepair(nodes[1], nodes[3], "C"),
CascadeRepair(nodes[2], nodes[4], "D")
)
}
shouldThrow<IllegalStateException> {
cascade.validate()
}
}
}
context("creating a star topology") {
val cascade = TestCascade()
val nodes = Array(numNodes) { i -> TestCascadeNode(i.toString()) }
cascade.addNodeToMesh(nodes[0], "A")
for (i in 1 until numNodes) {
cascade.addNodeToMesh(nodes[i], ('A'.code + i).toChar().toString(), nodes[0])
}
should("create the correct links for each node") {
cascade.getMeshNodes("A").size shouldBe 0
for (m in 'B'..'E') {
cascade.getMeshNodes(m.toString()).size shouldBe 2
}
nodes[0].relays.size shouldBe 4
for (i in 1 until numNodes) {
nodes[i].relays.size shouldBe 1
}
}
should("validate") {
cascade.validate()
}
should("enumerate only one node behind each leaf node from the core node") {
for (i in 1 until numNodes) {
cascade.getNodesBehind(nodes[0], nodes[i]).shouldContainExactlyInAnyOrder(nodes[i])
}
}
should("enumerate all the other nodes behind the core node from each leaf node") {
cascade.getNodesBehind(nodes[1], nodes[0])
.shouldContainExactlyInAnyOrder(nodes[0], nodes[2], nodes[3], nodes[4])
cascade.getNodesBehind(nodes[2], nodes[0])
.shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[3], nodes[4])
cascade.getNodesBehind(nodes[3], nodes[0])
.shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2], nodes[4])
cascade.getNodesBehind(nodes[4], nodes[0])
.shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2], nodes[3])
}
should("have paths directly from the core node") {
var pathsVisited = 0
cascade.getPathsFrom(nodes[0]) { c, n, from ->
c shouldBe cascade
if (n == nodes[0]) {
from shouldBe null
} else {
from shouldBe nodes[0]
}
pathsVisited++
}
pathsVisited shouldBe numNodes
}
should("have paths from other nodes through the core node") {
for (i in 1 until numNodes) {
var pathsVisited = 0
cascade.getPathsFrom(nodes[i]) { c, n, from ->
c shouldBe cascade
when (n) {
nodes[i] -> from shouldBe null
nodes[0] -> from shouldBe nodes[i]
else -> from shouldBe nodes[0]
}
pathsVisited++
}
pathsVisited shouldBe numNodes
}
}
should("have the correct distance from each node to another") {
for (i in 1 until numNodes) {
cascade.getDistanceFrom(nodes[i]) { it == nodes[0] } shouldBe 1
cascade.getDistanceFrom(nodes[0]) { it == nodes[i] } shouldBe 1
for (j in 1 until numNodes) {
cascade.getDistanceFrom(nodes[i]) { it == nodes[j] } shouldBe if (i == j) 0 else 2
}
cascade.getDistanceFrom(nodes[i]) { false } shouldBe Int.MAX_VALUE
}
}
should("not call a callback when removing a leaf node") {
for (i in 1 until numNodes) {
var called = false
cascade.removeNode(nodes[i]) { _, _ ->
called = true
setOf()
}
called shouldBe false
}
}
should("call the callback when removing the core node") {
var called = false
cascade.removeNode(nodes[0]) { _, disconnectedMeshes ->
called = true
disconnectedMeshes.size shouldBe 4
disconnectedMeshes.shouldContainExactlyInAnyOrder(
setOf(nodes[1]),
setOf(nodes[2]),
setOf(nodes[3]),
setOf(nodes[4])
)
setOf()
}
called shouldBe true
}
should("fail validation if not repaired after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ -> setOf() }
shouldThrow<IllegalStateException> {
cascade.validate()
}
}
should("validate when repaired correctly after removing the core node") {
cascade.removeNode(nodes[0]) { _, _ ->
setOf(
CascadeRepair(nodes[1], nodes[2], "B"),
CascadeRepair(nodes[1], nodes[3], "C"),
CascadeRepair(nodes[1], nodes[4], "D")
)
}
cascade.validate()
}
}
}
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withNewConfig
import org.jxmpp.jid.impl.JidCreate
/**
* Tests the `max-bridge-participants-per-interval` limit, i.e. a bridge being considered overloaded for a specific
* conference because that conference recently added too many endpoints to it.
*/
class MaxParticipantsPerIntervalTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private fun createBridge(name: String, stress: Double) = Bridge(JidCreate.from(name)).apply {
setStats(stress = stress, region = REGION)
}
private val bridge1 = createBridge("bridge1", 0.0)
private val bridge2 = createBridge("bridge2", 0.1)
private val bridges = listOf(bridge1, bridge2)
private val participant = ParticipantProperties(REGION)
/** The conference has [recentlyAdded] endpoints on [bridge1] and none on [bridge2] (which it doesn't use). */
private fun onlyBridge1(recentlyAdded: Long) = mapOf(bridge1 to conferenceBridge(recentlyAdded))
private fun conferenceBridge(recentlyAdded: Long, participantCount: Int = 1) =
ConferenceBridgeProperties(participantCount, false, recentlyAdded)
init {
context("With the limit disabled (the default)") {
BridgeConfig.config.maxBridgeParticipantsPerInterval shouldBe -1
with(RegionBasedBridgeSelectionStrategy()) {
should("not affect selection regardless of the number of recently added endpoints") {
select(bridges, onlyBridge1(1000), participant, true) shouldBe bridge1
}
}
}
context("With the limit enabled") {
withNewConfig("$MAX_PER_INTERVAL_CONFIG=$MAX_PER_INTERVAL") {
with(RegionBasedBridgeSelectionStrategy()) {
should("keep using the bridge below the limit") {
select(bridges, onlyBridge1(MAX_PER_INTERVAL - 1L), participant, true) shouldBe bridge1
}
should("select another bridge once the limit is reached") {
select(bridges, onlyBridge1(MAX_PER_INTERVAL.toLong()), participant, true) shouldBe bridge2
select(bridges, onlyBridge1(MAX_PER_INTERVAL + 100L), participant, true) shouldBe bridge2
}
should("prefer another bridge that the conference already uses") {
val bridge3 = createBridge("bridge3", 0.2)
select(
bridges + bridge3,
mapOf(
bridge1 to conferenceBridge(MAX_PER_INTERVAL.toLong()),
bridge3 to conferenceBridge(0)
),
participant,
true
) shouldBe bridge3
}
should("fall through to the least loaded bridge when all bridges are over the limit") {
select(
bridges,
mapOf(
bridge1 to conferenceBridge(MAX_PER_INTERVAL.toLong()),
bridge2 to conferenceBridge(MAX_PER_INTERVAL.toLong())
),
participant,
true
) shouldBe bridge1
}
should("not affect the initial selection for a conference with no bridges") {
select(bridges, emptyMap(), participant, true) shouldBe bridge1
}
}
context("With IntraRegionBridgeSelectionStrategy") {
with(IntraRegionBridgeSelectionStrategy()) {
should("keep using the bridge below the limit") {
select(bridges, onlyBridge1(MAX_PER_INTERVAL - 1L), participant, true) shouldBe bridge1
}
should("select another bridge once the limit is reached") {
select(bridges, onlyBridge1(MAX_PER_INTERVAL.toLong()), participant, true) shouldBe bridge2
}
}
}
context("With all bridges overloaded") {
// The last-resort tier (leastLoadedNotMaxedAlreadyInConference) intentionally does not honor the
// rate limit: it exists to return *some* bridge rather than fail the allocation.
val overloaded1 = createBridge("overloaded1", 0.9)
val overloaded2 = createBridge("overloaded2", 0.95)
overloaded1.isOverloaded shouldBe true
with(RegionBasedBridgeSelectionStrategy()) {
should("still select a bridge that the conference already uses") {
select(
listOf(overloaded1, overloaded2),
mapOf(
overloaded1 to conferenceBridge(MAX_PER_INTERVAL.toLong()),
overloaded2 to conferenceBridge(MAX_PER_INTERVAL.toLong())
),
participant,
true
) shouldBe overloaded1
}
}
}
context("The bridge-level state") {
should("not be affected") {
// The limit is specific to a conference. Nothing about the bridge itself changes, so other
// conferences (and load redistribution, sorting, etc) are unaffected.
val conferenceBridges = onlyBridge1(MAX_PER_INTERVAL + 100L)
with(RegionBasedBridgeSelectionStrategy()) {
select(bridges, conferenceBridges, participant, true) shouldBe bridge2
}
bridge1.isOverloaded shouldBe false
bridge1.correctedStress shouldBe 0.0
// A different conference, which has not added anything to bridge1, still gets bridge1.
with(RegionBasedBridgeSelectionStrategy()) {
select(bridges, mapOf(bridge1 to conferenceBridge(0)), participant, true) shouldBe bridge1
}
}
}
}
}
}
}
private const val REGION = "region"
private const val MAX_PER_INTERVAL = 10
private const val MAX_PER_INTERVAL_CONFIG = "jicofo.bridge.max-bridge-participants-per-interval"

View File

@@ -0,0 +1,216 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.bridge.Regions.ApSouth
import org.jitsi.jicofo.bridge.Regions.EuCentral
import org.jitsi.jicofo.bridge.Regions.EuWest
import org.jitsi.jicofo.bridge.Regions.UsEast
import org.jitsi.jicofo.bridge.Regions.UsWest
import org.jitsi.jicofo.bridge.StressLevels.High
import org.jitsi.jicofo.bridge.StressLevels.Low
import org.jitsi.jicofo.bridge.StressLevels.Medium
class RegionBasedSelectionTest : ShouldSpec() {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
private val bridges = createBridges()
private val bridgesList = bridges.values.flatMap { it.values }
private fun BridgeSelectionStrategy.select(
bridges: List<Bridge> = bridgesList,
conferenceBridges: Map<Bridge, ConferenceBridgeProperties> = emptyMap(),
participantRegion: Regions? = null
) = select(bridges, conferenceBridges, ParticipantProperties(participantRegion?.region), true)
init {
context("Without region groups") {
withNewConfig(MAX_BP_CONFIG) {
BridgeConfig.config.maxBridgeParticipants shouldBe MAX_BP
with(RegionBasedBridgeSelectionStrategy()) {
context("In a single region") {
select()!!.correctedStress shouldBe Low.stress
select(participantRegion = ApSouth) shouldBe bridges[ApSouth][Low]
select(
participantRegion = ApSouth,
conferenceBridges = mapOf(bridges[ApSouth][Medium] to ConferenceBridgeProperties(1))
) shouldBe bridges[ApSouth][Medium]
select(
participantRegion = ApSouth,
conferenceBridges = mapOf(bridges[ApSouth][Medium] to ConferenceBridgeProperties(MAX_BP))
) shouldBe bridges[ApSouth][Low]
select(
participantRegion = ApSouth,
conferenceBridges = mapOf(bridges[ApSouth][Low] to ConferenceBridgeProperties(MAX_BP))
) shouldBe bridges[ApSouth][Medium]
select(
participantRegion = ApSouth,
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[ApSouth][Medium] to ConferenceBridgeProperties(1)
)
) shouldBe bridges[ApSouth][Low]
select(
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[ApSouth][Medium] to ConferenceBridgeProperties(1)
)
) shouldBe bridges[ApSouth][Low]
}
context("In multiple regions") {
select(
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[EuWest][Medium] to ConferenceBridgeProperties(1),
bridges[EuWest][High] to ConferenceBridgeProperties(1)
)
) shouldBe bridges[ApSouth][Low]
select(
participantRegion = EuWest,
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[EuWest][Medium] to ConferenceBridgeProperties(1),
bridges[EuWest][High] to ConferenceBridgeProperties(1)
)
) shouldBe bridges[EuWest][Medium]
select(
participantRegion = EuCentral,
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[EuWest][Medium] to ConferenceBridgeProperties(1),
bridges[EuWest][High] to ConferenceBridgeProperties(1)
)
) shouldBe bridges[EuCentral][Low]
select(
participantRegion = EuCentral,
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[EuWest][Medium] to ConferenceBridgeProperties(1),
bridges[EuWest][High] to ConferenceBridgeProperties(1),
bridges[EuCentral][Low] to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe bridges[EuCentral][Medium]
}
}
}
}
context("With region groups") {
withNewConfig(regionGroupsConfig) {
BridgeConfig.config.maxBridgeParticipants shouldBe MAX_BP
with(RegionBasedBridgeSelectionStrategy()) {
select(participantRegion = ApSouth) shouldBe bridges[ApSouth][Low]
select(participantRegion = EuWest) shouldBe bridges[EuWest][Low]
// When there are no bridges available in the region, regions in the group should be preferred
select(
participantRegion = EuWest,
bridges = bridgesList.filterNot { it.region == EuWest.region }
) shouldBe bridges[EuCentral][Low]
select(
participantRegion = EuWest,
bridges = bridgesList.filterNot { it.region == EuWest.region },
conferenceBridges = mapOf(
bridges[UsEast][Low] to ConferenceBridgeProperties(1)
)
) shouldBe bridges[EuCentral][Low]
select(
participantRegion = EuCentral,
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[EuWest][Medium] to ConferenceBridgeProperties(1),
bridges[EuWest][High] to ConferenceBridgeProperties(1),
bridges[EuCentral][Low] to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe bridges[EuWest][Medium]
select(
participantRegion = UsEast,
conferenceBridges = mapOf(
bridges[ApSouth][Low] to ConferenceBridgeProperties(2),
bridges[EuWest][Medium] to ConferenceBridgeProperties(1),
bridges[EuWest][High] to ConferenceBridgeProperties(1),
bridges[EuCentral][Low] to ConferenceBridgeProperties(MAX_BP)
)
) shouldBe bridges[UsEast][Low]
context("Initial selection in the local region group, but not in the local region") {
select(participantRegion = UsWest, conferenceBridges = emptyMap()) shouldBe bridges[UsEast][Low]
}
}
}
}
}
}
private const val MAX_BP = 10
const val MAX_BP_CONFIG = "jicofo.bridge.max-bridge-participants=$MAX_BP"
val regionGroupsConfig = """
jicofo.local-region = ${UsEast.region}
jicofo.bridge.selection-strategy=RegionBasedBridgeSelectionStrategy
jicofo.bridge.region-groups = [
[ "${UsEast.region}", "${UsWest.region}" ],
[ "${EuCentral.region}", "${EuWest.region}" ],
]
$MAX_BP_CONFIG
""".trimIndent()
private fun mockBridge(r: Regions, s: StressLevels) = mockk<Bridge> {
every { region } returns r.region
every { correctedStress } returns s.stress
every { isOverloaded } returns (s == High)
every { lastReportedStressLevel } returns s.stress
every { relayId } returns "dummy"
every { this@mockk.toString() } returns "MockBridge[region=$region, stress=$correctedStress]"
}
// Create a Low, Medium and High stress bridge in each region.
private fun createBridges() = RegionToBridgesMap(mutableMapOf()).apply {
Regions.values().forEach { r ->
put(r, StressToBridgeMap(StressLevels.values().associateWith { mockBridge(r, it) }))
}
}
// Hide the "!!" here for convenience.
private class RegionToBridgesMap(
private val m: MutableMap<Regions, StressToBridgeMap>
) : MutableMap<Regions, StressToBridgeMap> by m {
override fun get(key: Regions): StressToBridgeMap = m[key]!!
}
// Hide the "!!" here for convenience.
private class StressToBridgeMap(private val m: Map<StressLevels, Bridge>) : Map<StressLevels, Bridge> by m {
override fun get(key: StressLevels): Bridge = m[key]!!
}
private enum class Regions(val region: String) {
ApSouth("ap-south"),
EuCentral("eu-central"),
EuWest("eu-west"),
UsEast("us-east"),
UsWest("us-west")
}
private enum class StressLevels(val stress: Double) {
Low(0.0),
Medium(0.3),
High(0.9)
}

View File

@@ -0,0 +1,41 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension
class UtilTest : ShouldSpec() {
init {
context("ColibriStatsExtension.getDouble test") {
with(ColibriStatsExtension()) {
addStat("int", 5)
addStat("double", 5.0)
addStat("valid-string", "5")
addStat("invalid-string", "five")
getDouble("int") shouldBe 5.toDouble()
getDouble("double") shouldBe 5.toDouble()
getDouble("valid-string") shouldBe 5.toDouble()
getDouble("invalid-string") shouldBe null
getDouble("non-existent-key") shouldBe null
}
}
}
}

View File

@@ -0,0 +1,217 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.bridge.Bridge
import org.jitsi.jicofo.bridge.BridgeSelector
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.jicofo.mock.MockXmppConnection
import org.jitsi.jicofo.mock.TestColibri2Server
import org.jitsi.jicofo.mock.inPlaceExecutor
import org.jitsi.jicofo.mock.inPlaceScheduledExecutor
import org.jitsi.utils.TemplatedUrl
import org.jitsi.utils.logging2.createLogger
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
import org.jitsi.xmpp.extensions.colibri2.Connect
import org.jivesoftware.smack.packet.IQ
import org.jxmpp.jid.impl.JidCreate
/**
* Tests the colibri2 signaling of the text-translation target languages: they ride on the transcriber connect's
* `requests` list as bare language codes, so changing them updates the existing connect rather than reconnecting it.
*/
class ColibriTextTranslationTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private val colibriRequests = mutableListOf<ConferenceModifyIQ>()
private val colibri2Server = TestColibri2Server()
private val xmppConnection = object : MockXmppConnection() {
override fun handleIq(iq: IQ): IQ? {
if (iq is ConferenceModifyIQ) {
colibriRequests.add(iq)
return colibri2Server.handleConferenceModifyIq(iq)
}
return null
}
}
private val bridge: Bridge = mockk(relaxed = true) {
every { jid } returns JidCreate.from("jvb@example.com/jvb1")
every { relayId } returns null
every { isOperational } returns true
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { region } returns "us-east"
}
private val bridgeSelector: BridgeSelector = mockk {
every { selectBridge(any(), any(), any()) } returns bridge
}
private fun createSessionManager() = ColibriV2SessionManager(
xmppConnection.xmppConnection,
bridgeSelector,
"test-conference",
"test-meeting-id",
false,
null,
createLogger()
)
private fun allocateParticipant(manager: ColibriV2SessionManager, id: String) = manager.allocate(
ParticipantAllocationParameters(
id = id,
statsId = null,
region = null,
sources = EndpointSourceSet.EMPTY,
useSsrcRewriting = false,
useRtpMidDemux = false,
forceMuteAudio = false,
forceMuteVideo = false,
useSctp = false,
visitor = false,
supportsPrivateAddresses = false,
diarize = false,
medias = emptySet()
)
)
override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also {
TaskPools.ioPool = inPlaceExecutor
TaskPools.scheduledPool = inPlaceScheduledExecutor
}
override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also {
TaskPools.resetIoPool()
TaskPools.resetScheduledPool()
}
init {
val transcriberUrl = TemplatedUrl(
"wss://{{REGION}}.transcribe.example.com/transcribe",
requiredKeys = setOf("REGION")
)
fun lastTranscriberConnect(): Connect? = colibriRequests
.lastOrNull { iq -> iq.connects?.getConnects()?.any { it.type == Connect.Types.TRANSCRIBER } == true }
?.connects?.getConnects()?.first { it.type == Connect.Types.TRANSCRIBER }
context("Requesting languages after the transcriber is connected") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTranscriberUrl(transcriberUrl)
colibriRequests.clear()
manager.setTextTranslationLanguages(setOf("fr", "de"))
val connect = lastTranscriberConnect()
should("update the existing transcriber connect rather than create a new one") {
connect shouldNotBe null
connect!!.id shouldBe "transcriber"
connect.create shouldBe false
connect.expire shouldBe false
}
should("carry the languages as the connect requests") {
connect!!.getRequests() shouldBe listOf("de", "fr")
}
should("not change the url, so the bridge keeps the same websocket") {
connect!!.url.toString() shouldBe "wss://us-east.transcribe.example.com/transcribe"
}
}
context("Requesting languages before the transcriber is connected") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTextTranslationLanguages(setOf("fr"))
colibriRequests.clear()
manager.setTranscriberUrl(transcriberUrl)
should("apply them to the transcriber connect when it is created") {
val connect = lastTranscriberConnect()
connect shouldNotBe null
connect!!.create shouldBe true
connect.getRequests() shouldBe listOf("fr")
}
}
context("Changing the requested languages") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTranscriberUrl(transcriberUrl)
manager.setTextTranslationLanguages(setOf("fr"))
colibriRequests.clear()
manager.setTextTranslationLanguages(setOf("fr", "es"))
should("re-signal the connect with the new set") {
lastTranscriberConnect()!!.getRequests() shouldBe listOf("es", "fr")
}
}
context("Requesting the same languages again") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTranscriberUrl(transcriberUrl)
manager.setTextTranslationLanguages(setOf("fr", "de"))
colibriRequests.clear()
should("not signal anything when the set is unchanged") {
manager.setTextTranslationLanguages(setOf("fr", "de"))
lastTranscriberConnect() shouldBe null
}
should("not signal anything when only the order differs") {
manager.setTextTranslationLanguages(linkedSetOf("de", "fr"))
lastTranscriberConnect() shouldBe null
}
}
context("Giving up the last language") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTranscriberUrl(transcriberUrl)
manager.setTextTranslationLanguages(setOf("fr"))
colibriRequests.clear()
manager.setTextTranslationLanguages(emptySet())
should("signal an empty requests list, which stops translation") {
val connect = lastTranscriberConnect()
connect shouldNotBe null
connect!!.expire shouldBe false
connect.getRequests() shouldBe emptyList()
}
}
context("Requesting languages with no transcriber at all") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
colibriRequests.clear()
manager.setTextTranslationLanguages(setOf("fr"))
should("not signal a transcriber connect") {
lastTranscriberConnect() shouldBe null
}
}
}
}

View File

@@ -0,0 +1,243 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2026 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.bridge.Bridge
import org.jitsi.jicofo.bridge.BridgeSelector
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.jicofo.mock.MockXmppConnection
import org.jitsi.jicofo.mock.TestColibri2Server
import org.jitsi.jicofo.mock.inPlaceExecutor
import org.jitsi.jicofo.mock.inPlaceScheduledExecutor
import org.jitsi.utils.TemplatedUrl
import org.jitsi.utils.logging2.createLogger
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
import org.jivesoftware.smack.packet.IQ
import org.jxmpp.jid.impl.JidCreate
/**
* Tests that custom transcription HTTP headers and URL parameters are correctly included in Colibri2 requests,
* covering both the case where transcription is configured before the first session is created (bug fix) and
* after a session already exists.
*/
class ColibriTranscriptionTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private val colibriRequests = mutableListOf<ConferenceModifyIQ>()
private val colibri2Server = TestColibri2Server()
private val xmppConnection = object : MockXmppConnection() {
override fun handleIq(iq: IQ): IQ? {
if (iq is ConferenceModifyIQ) {
colibriRequests.add(iq)
return colibri2Server.handleConferenceModifyIq(iq)
}
return null
}
}
private val bridge: Bridge = mockk(relaxed = true) {
every { jid } returns JidCreate.from("jvb@example.com/jvb1")
every { relayId } returns null
every { isOperational } returns true
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { region } returns "us-east"
}
private val bridgeSelector: BridgeSelector = mockk {
every { selectBridge(any(), any(), any()) } returns bridge
}
private fun createSessionManager() = ColibriV2SessionManager(
xmppConnection.xmppConnection,
bridgeSelector,
"test-conference",
"test-meeting-id",
false,
null,
createLogger()
)
private fun allocateParticipant(manager: ColibriV2SessionManager, id: String = "p1", diarize: Boolean = false) =
manager.allocate(
ParticipantAllocationParameters(
id = id,
statsId = null,
region = null,
sources = EndpointSourceSet.EMPTY,
useSsrcRewriting = false,
useRtpMidDemux = false,
forceMuteAudio = false,
forceMuteVideo = false,
useSctp = false,
visitor = false,
supportsPrivateAddresses = false,
diarize = diarize,
medias = emptySet()
)
)
override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also {
TaskPools.ioPool = inPlaceExecutor
TaskPools.scheduledPool = inPlaceScheduledExecutor
}
override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also {
TaskPools.resetIoPool()
TaskPools.resetScheduledPool()
}
init {
// bridge.region = "us-east", so the URL becomes wss://us-east.transcriber.example.com/rec
val transcriberUrl = TemplatedUrl(
"wss://{{REGION}}.transcriber.example.com/rec",
requiredKeys = setOf("REGION")
)
val customHeaders = mapOf("X-Custom-Header" to "custom-value", "Authorization" to "Bearer custom")
val urlParams = mapOf("key1" to "value1", "key2" to "hello world")
val expectedBaseUrl = "wss://us-east.transcriber.example.com/rec"
context("Transcription configured before session exists") {
// This covers the bug fix: when setTranscriberUrl is called before any Colibri session exists,
// the custom headers and URL params must be included in the initial create request
// (via sendAllocationRequest → createRequest(create=true)).
val manager = createSessionManager()
manager.setTranscriberUrl(transcriberUrl, customHeaders, urlParams)
allocateParticipant(manager)
val createRequest = colibriRequests.find { it.create }
createRequest shouldNotBe null
val connect = createRequest!!.connects?.getConnects()?.firstOrNull()
should("include a Connect in the create request") {
connect shouldNotBe null
}
should("use custom HTTP headers") {
val headers = connect!!.getHttpHeaders().associate { it.name to it.value }
headers["X-Custom-Header"] shouldBe "custom-value"
headers["Authorization"] shouldBe "Bearer custom"
}
should("append URL params to the transcriber URL") {
val urlStr = connect!!.url.toString()
urlStr shouldContain "$expectedBaseUrl?"
urlStr shouldContain "key1=value1"
urlStr shouldContain "key2=hello+world"
}
}
context("Transcription configured after session exists") {
// Verifies the working path: setTranscriberUrl called after a session exists sends the
// custom headers and URL params in the update request.
val manager = createSessionManager()
allocateParticipant(manager)
colibriRequests.clear()
manager.setTranscriberUrl(transcriberUrl, customHeaders, urlParams)
val setUrlRequest = colibriRequests.firstOrNull()
setUrlRequest shouldNotBe null
val connect = setUrlRequest!!.connects?.getConnects()?.firstOrNull()
should("include a Connect in the setTranscriberUrl request") {
connect shouldNotBe null
}
should("use custom HTTP headers") {
val headers = connect!!.getHttpHeaders().associate { it.name to it.value }
headers["X-Custom-Header"] shouldBe "custom-value"
headers["Authorization"] shouldBe "Bearer custom"
}
should("append URL params to the transcriber URL") {
val urlStr = connect!!.url.toString()
urlStr shouldContain "$expectedBaseUrl?"
urlStr shouldContain "key1=value1"
urlStr shouldContain "key2=hello+world"
}
}
context("Diarize flag") {
context("Participant with diarize=true") {
val manager = createSessionManager()
allocateParticipant(manager, diarize = true)
val createRequest = colibriRequests.find { it.create }!!
val endpoint = createRequest.endpoints.first()
should("set the diarize attribute on the endpoint") {
endpoint.diarize shouldBe true
}
}
context("Participant with diarize=false") {
val manager = createSessionManager()
allocateParticipant(manager, diarize = false)
val createRequest = colibriRequests.find { it.create }!!
val endpoint = createRequest.endpoints.first()
should("not set the diarize attribute on the endpoint") {
// The attribute is omitted, so getDiarize() returns null.
endpoint.diarize shouldBe null
}
}
}
context("Transcription with no custom headers or params") {
context("Configured before session exists") {
val manager = createSessionManager()
manager.setTranscriberUrl(transcriberUrl, null, null)
allocateParticipant(manager)
val createRequest = colibriRequests.find { it.create }!!
val connect = createRequest.connects?.getConnects()?.firstOrNull()
should("include a Connect") { connect shouldNotBe null }
should("use the base URL without query params") {
connect!!.url.toString() shouldBe expectedBaseUrl
}
should("include no HTTP headers") {
connect!!.getHttpHeaders() shouldBe emptyList()
}
}
context("Configured after session exists") {
val manager = createSessionManager()
allocateParticipant(manager)
colibriRequests.clear()
manager.setTranscriberUrl(transcriberUrl, null, null)
val setUrlRequest = colibriRequests.firstOrNull()!!
val connect = setUrlRequest.connects?.getConnects()?.firstOrNull()
should("include a Connect") { connect shouldNotBe null }
should("use the base URL without query params") {
connect!!.url.toString() shouldBe expectedBaseUrl
}
should("include no HTTP headers") {
connect!!.getHttpHeaders() shouldBe emptyList()
}
}
}
}
}

View File

@@ -0,0 +1,217 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.bridge.Bridge
import org.jitsi.jicofo.bridge.BridgeSelector
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.jicofo.mock.MockXmppConnection
import org.jitsi.jicofo.mock.TestColibri2Server
import org.jitsi.jicofo.mock.inPlaceExecutor
import org.jitsi.jicofo.mock.inPlaceScheduledExecutor
import org.jitsi.utils.TemplatedUrl
import org.jitsi.utils.logging2.createLogger
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
import org.jitsi.xmpp.extensions.colibri2.Connect
import org.jivesoftware.smack.packet.IQ
import org.jxmpp.jid.impl.JidCreate
/**
* Tests the colibri2 signaling for live translation in the default per-source mode: each sender's connect is placed on
* its (single, here) bridge, signaled as create/update/expire deltas, keyed by a per-source connect id.
*/
class ColibriTranslationTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private val colibriRequests = mutableListOf<ConferenceModifyIQ>()
private val colibri2Server = TestColibri2Server()
private val xmppConnection = object : MockXmppConnection() {
override fun handleIq(iq: IQ): IQ? {
if (iq is ConferenceModifyIQ) {
colibriRequests.add(iq)
return colibri2Server.handleConferenceModifyIq(iq)
}
return null
}
}
private val bridge: Bridge = mockk(relaxed = true) {
every { jid } returns JidCreate.from("jvb@example.com/jvb1")
every { relayId } returns null
every { isOperational } returns true
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { region } returns "us-east"
}
private val bridgeSelector: BridgeSelector = mockk {
every { selectBridge(any(), any(), any()) } returns bridge
}
private fun createSessionManager() = ColibriV2SessionManager(
xmppConnection.xmppConnection,
bridgeSelector,
"test-conference",
"test-meeting-id",
false,
null,
createLogger()
)
private fun allocateParticipant(manager: ColibriV2SessionManager, id: String) = manager.allocate(
ParticipantAllocationParameters(
id = id,
statsId = null,
region = null,
sources = EndpointSourceSet.EMPTY,
useSsrcRewriting = false,
useRtpMidDemux = false,
forceMuteAudio = false,
forceMuteVideo = false,
useSctp = false,
visitor = false,
supportsPrivateAddresses = false,
diarize = false,
medias = emptySet()
)
)
override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also {
TaskPools.ioPool = inPlaceExecutor
TaskPools.scheduledPool = inPlaceScheduledExecutor
}
override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also {
TaskPools.resetIoPool()
TaskPools.resetScheduledPool()
}
init {
val translatorUrl = TemplatedUrl(
"wss://{{REGION}}.translate.example.com/t",
requiredKeys = setOf("REGION")
)
val expectedUrl = "wss://us-east.translate.example.com/t"
fun lastConnect() = colibriRequests.lastOrNull { it.connects != null }?.connects?.getConnects()?.firstOrNull()
context("Enabling per-source translation for a sender") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
colibriRequests.clear()
manager.setTranslator(
translatorUrl,
listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en", "p1-a0.es")))
)
val connect = lastConnect()
should("send a per-source translator connect with create") {
connect shouldNotBe null
connect!!.id shouldBe "translator-p1-a0-0"
connect.type shouldBe Connect.Types.TRANSLATOR
connect.create shouldBe true
connect.expire shouldBe false
}
should("export the sender's source and request its synthetic outputs") {
connect!!.getExports() shouldBe listOf("p1-a0")
connect.getRequests() shouldBe listOf("p1-a0.en", "p1-a0.es")
}
should("resolve the url for the bridge region") {
connect!!.url.toString() shouldBe expectedUrl
}
}
context("Updating the requested languages") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTranslator(translatorUrl, listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en"))))
colibriRequests.clear()
manager.setTranslator(
translatorUrl,
listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en", "p1-a0.es")))
)
val connect = lastConnect()
should("re-signal the same connect as an update (not create)") {
connect shouldNotBe null
connect!!.id shouldBe "translator-p1-a0-0"
connect.create shouldBe false
connect.expire shouldBe false
connect.getRequests() shouldBe listOf("p1-a0.en", "p1-a0.es")
}
}
context("Disabling translation") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
manager.setTranslator(translatorUrl, listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en"))))
colibriRequests.clear()
manager.setTranslator(null, emptyList())
val connect = lastConnect()
should("expire the connect") {
connect shouldNotBe null
connect!!.id shouldBe "translator-p1-a0-0"
connect.expire shouldBe true
}
}
context("With per-room translator headers") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
colibriRequests.clear()
manager.setTranslator(
translatorUrl,
listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en"))),
customHeaders = mapOf("Authorization" to "Bearer per-room", "X-Custom" to "v1")
)
val connect = lastConnect()
should("put the per-room headers on the connect") {
connect shouldNotBe null
connect!!.getHttpHeaders().associate { it.name to it.value } shouldBe mapOf(
"Authorization" to "Bearer per-room",
"X-Custom" to "v1"
)
}
}
context("Without per-room translator headers") {
val manager = createSessionManager()
allocateParticipant(manager, "p1")
colibriRequests.clear()
// No customHeaders: fall back to the (empty, in this test) static config headers.
manager.setTranslator(translatorUrl, listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en"))))
val connect = lastConnect()
should("fall back to the config headers (none configured here)") {
connect shouldNotBe null
connect!!.getHttpHeaders().associate { it.name to it.value } shouldBe emptyMap()
}
}
}
}

View File

@@ -0,0 +1,462 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2026 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.mockk.mockk
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.bridge.Bridge
import org.jitsi.jicofo.bridge.BridgeConfig
import org.jitsi.jicofo.bridge.BridgeSelector
import org.jitsi.jicofo.bridge.ParticipantProperties
import org.jitsi.jicofo.conference.source.EndpointSourceSet
import org.jitsi.jicofo.mock.MockXmppConnection
import org.jitsi.jicofo.mock.PendingExecutor
import org.jitsi.jicofo.mock.TestColibri2Server
import org.jitsi.jicofo.mock.inPlaceScheduledExecutor
import org.jitsi.utils.logging2.createLogger
import org.jitsi.utils.ms
import org.jitsi.utils.time.FakeClock
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension
import org.jitsi.xmpp.extensions.jingle.IceCandidatePacketExtension
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
import org.jivesoftware.smack.packet.IQ
import org.jxmpp.jid.Jid
import org.jxmpp.jid.impl.JidCreate
private fun transportWithGeneration(generation: Int?) = IceUdpTransportPacketExtension().apply {
ufrag = "ufrag-$generation"
password = "password-$generation"
generation?.let { setIceGeneration(it) }
}
/** A transport as a participant signals it: ICE credentials, with no `ice-generation` of its own. */
private fun transportWithCredentials(ufrag: String) = IceUdpTransportPacketExtension().apply {
this.ufrag = ufrag
password = "password-$ufrag"
}
/** A transport as a participant signals it when it trickles a candidate: no credentials. */
private fun transportWithCandidateOnly() = IceUdpTransportPacketExtension().apply {
addCandidate(IceCandidatePacketExtension())
}
/**
* Tests [ColibriV2SessionManager] against [TestColibri2Server]s, including multi-bridge (Octo) conferences with
* relays.
*/
class ColibriV2SessionManagerTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
/** Requests jicofo sent, per bridge JID. */
private val colibriRequests = mutableMapOf<Jid, MutableList<ConferenceModifyIQ>>()
/** One colibri2 server per bridge JID, IQs are routed by their `to` address. */
private val colibriServers = mutableMapOf<Jid, TestColibri2Server>()
private val xmppConnection = object : MockXmppConnection() {
override fun handleIq(iq: IQ): IQ? {
if (iq is ConferenceModifyIQ) {
colibriRequests.computeIfAbsent(iq.to) { mutableListOf() }.add(iq)
return colibriServers.computeIfAbsent(iq.to) { TestColibri2Server() }.handleConferenceModifyIq(iq)
}
return null
}
}
private fun createBridge(name: String, bridgeRelayId: String? = name, bridgeRegion: String = "region-$name") =
mockk<Bridge>(relaxed = true) {
every { jid } returns JidCreate.from("jvbbrewery@example.com/$name")
every { relayId } returns bridgeRelayId
every { isOperational } returns true
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { region } returns bridgeRegion
}
private val bridge1 = createBridge("jvb1")
private val bridge2 = createBridge("jvb2")
private val bridgeSelector: BridgeSelector = mockk {
every { selectBridge(any(), any(), any()) } answers {
// Select a bridge matching the participant's region, if any.
val region = secondArg<ParticipantProperties>().region
listOf(bridge1, bridge2).find { it.region == region } ?: bridge1
}
}
private val failedSessions = mutableListOf<Bridge>()
private val removedEndpoints = mutableListOf<String>()
/** The transports relayed to participants after an ICE restart, in the order they were fired. */
private val iceRestartedTransports = mutableListOf<Pair<String, IceUdpTransportPacketExtension>>()
private val listener = object : ColibriSessionManager.Listener {
override fun bridgeCountChanged(bridgeCount: Int) {}
override fun bridgeRemoved(bridge: Bridge, participantIds: List<String>) {
failedSessions.add(bridge)
}
override fun endpointRemoved(endpointId: String) {
removedEndpoints.add(endpointId)
}
override fun endpointIceRestarted(endpointId: String, transport: IceUdpTransportPacketExtension) {
iceRestartedTransports.add(endpointId to transport)
}
}
/**
* "Async" tasks (response handling, event emission) are queued and executed by [drain], modeling the fact that
* in production they run on a separate thread after the initiating call has completed (and released its locks).
*/
private val ioExecutor = PendingExecutor()
private val clock = FakeClock()
// Initialized lazily so that construction happens after [beforeAny] has replaced the TaskPools executors.
private val sessionManager by lazy {
ColibriV2SessionManager(
xmppConnection.xmppConnection,
bridgeSelector,
"test-conference",
"test-meeting-id",
false,
null,
createLogger(),
clock
).apply { addListener(listener) }
}
private fun drain() = ioExecutor.runAll()
private fun allocate(id: String, region: String? = null) = sessionManager.allocate(
ParticipantAllocationParameters(
id = id,
statsId = null,
region = region,
sources = EndpointSourceSet.EMPTY,
useSsrcRewriting = false,
useRtpMidDemux = false,
forceMuteAudio = false,
forceMuteVideo = false,
useSctp = false,
visitor = false,
supportsPrivateAddresses = false,
medias = emptySet(),
diarize = false
)
).also { drain() }
private fun requestsTo(bridge: Bridge) = colibriRequests[bridge.jid] ?: emptyList()
override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also {
TaskPools.ioPool = ioExecutor.executor
TaskPools.scheduledPool = inPlaceScheduledExecutor
}
override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also {
TaskPools.resetIoPool()
TaskPools.resetScheduledPool()
}
init {
context("Single bridge") {
val allocation = allocate("p1", region = "region-jvb1")
should("allocate an endpoint on the bridge") {
allocation.shouldNotBeNull()
sessionManager.getBridges().keys shouldBe setOf(bridge1)
sessionManager.getParticipants(bridge1) shouldBe listOf("p1")
}
should("send a conference create request") {
requestsTo(bridge1).count { it.create } shouldBe 1
}
should("not create any relays") {
requestsTo(bridge1).flatMap { it.relays }.shouldBeEmpty()
}
context("And removing the participant") {
sessionManager.removeParticipant("p1").also { drain() }
should("expire the conference on the bridge") {
// Removing the last participant expires the whole colibri2 conference.
requestsTo(bridge1).count { it.expire } shouldBe 1
}
should("leave no bridges in the conference") {
sessionManager.getBridges().keys.shouldBeEmpty()
}
}
}
context("Two bridges") {
withNewConfig("jicofo.octo.enabled=true") {
allocate("p1", region = "region-jvb1")
allocate("p2", region = "region-jvb2")
should("allocate endpoints on both bridges") {
sessionManager.getBridges().keys shouldBe setOf(bridge1, bridge2)
sessionManager.getParticipants(bridge1) shouldBe listOf("p1")
sessionManager.getParticipants(bridge2) shouldBe listOf("p2")
}
should("create a relay on each bridge pointing to the other") {
val relaysOn1 = requestsTo(bridge1).flatMap { it.relays }.filter { it.create }
val relaysOn2 = requestsTo(bridge2).flatMap { it.relays }.filter { it.create }
relaysOn1.map { it.id } shouldBe listOf("jvb2")
relaysOn2.map { it.id } shouldBe listOf("jvb1")
}
should("exchange relay transports (one side active, one side passive)") {
val setups = listOf(bridge1, bridge2).map { bridge ->
// The transport update for the relay (create=false, with a transport).
val transportUpdate = requestsTo(bridge).flatMap { it.relays }
.filter { !it.create && it.transport?.iceUdpTransport != null }
transportUpdate.size shouldBe 1
transportUpdate.first().transport!!.iceUdpTransport!!.getChildExtensionsOfType(
DtlsFingerprintPacketExtension::class.java
).first().setup
}
setups.toSet() shouldBe setOf("active", "passive")
}
should("not fail any sessions") {
failedSessions.shouldBeEmpty()
}
should("signal each participant as a remote endpoint on the other bridge's relay") {
val relayEndpointsOn1 = requestsTo(bridge1).flatMap { it.relays }
.mapNotNull { it.endpoints }.flatMap { it.endpoints }.map { it.id }
val relayEndpointsOn2 = requestsTo(bridge2).flatMap { it.relays }
.mapNotNull { it.endpoints }.flatMap { it.endpoints }.map { it.id }
relayEndpointsOn1 shouldBe listOf("p2")
relayEndpointsOn2 shouldBe listOf("p1")
}
context("And removing the participant on the second bridge") {
sessionManager.removeParticipant("p2").also { drain() }
should("expire the second bridge's session and its relay") {
sessionManager.getBridges().keys shouldBe setOf(bridge1)
val relayExpires = requestsTo(bridge1).flatMap { it.relays }.filter { it.expire }
relayExpires.map { it.id } shouldBe listOf("jvb2")
}
should("not fail any sessions") {
failedSessions.shouldBeEmpty()
}
}
}
}
context("A bridge failing") {
withNewConfig("jicofo.octo.enabled=true") {
allocate("p1", region = "region-jvb1")
allocate("p2", region = "region-jvb2")
val removed = sessionManager.removeBridge(bridge2).also { drain() }
should("report the participants on the failed bridge") {
removed shouldBe listOf("p2")
}
should("expire the relay to the failed bridge") {
val relayExpires = requestsTo(bridge1).flatMap { it.relays }.filter { it.expire }
relayExpires.map { it.id } shouldBe listOf("jvb2")
}
should("keep the remaining bridge in the conference") {
sessionManager.getBridges().keys shouldBe setOf(bridge1)
sessionManager.getParticipants(bridge1) shouldBe listOf("p1")
}
}
}
context("Tracking recently added endpoints") {
withNewConfig("jicofo.octo.enabled=true") {
allocate("p1", region = "region-jvb1")
allocate("p2", region = "region-jvb1")
allocate("p3", region = "region-jvb2")
should("count the endpoints that this conference added to each bridge") {
sessionManager.getBridges()[bridge1]!!.recentlyAddedParticipantCount shouldBe 2
sessionManager.getBridges()[bridge2]!!.recentlyAddedParticipantCount shouldBe 1
}
should("not decrease the count when a participant is removed") {
sessionManager.removeParticipant("p2").also { drain() }
with(sessionManager.getBridges()[bridge1]!!) {
participantCount shouldBe 1
recentlyAddedParticipantCount shouldBe 2
}
}
should("decay the count after the configured interval") {
clock.elapse(BridgeConfig.config.maxBridgeParticipantsInterval + 100.ms)
sessionManager.getBridges()[bridge1]!!.recentlyAddedParticipantCount shouldBe 0
}
should("reset the count when the session is removed and re-created") {
sessionManager.removeParticipant("p1").also { drain() }
sessionManager.removeParticipant("p2").also { drain() }
sessionManager.getBridges().keys shouldBe setOf(bridge2)
allocate("p4", region = "region-jvb1")
sessionManager.getBridges()[bridge1]!!.recentlyAddedParticipantCount shouldBe 1
}
}
}
context("ICE restart") {
allocate("p1", region = "region-jvb1")
context("For an existing participant") {
sessionManager.restartIce("p1").also { drain() }
should("request an ICE restart for the endpoint from its bridge") {
val iceRestarts = requestsTo(bridge1).flatMap { it.endpoints }
.filter { it.transport?.iceRestart == true }
iceRestarts.map { it.id } shouldBe listOf("p1")
}
should("relay the bridge's rotated transport back to the participant") {
iceRestartedTransports.size shouldBe 1
val (endpointId, transport) = iceRestartedTransports.first()
endpointId shouldBe "p1"
// The bridge rotated its credentials and tagged them with the first generation.
transport.iceGeneration shouldBe 1
transport.ufrag shouldBe "ufrag-test-meeting-id-p1-1"
}
should("not fail the session") {
failedSessions.shouldBeEmpty()
}
context("And restarting again") {
sessionManager.restartIce("p1").also { drain() }
should("relay the next generation") {
iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(1, 2)
}
}
}
context("Tagging the participant's answer") {
// The bridge tells the credentials of its new Agent apart from an ordinary transport update by
// the `ice-generation`. The participant can not carry that attribute across SDP, so jicofo tags
// the answer on its behalf.
fun transportsToBridge() = requestsTo(bridge1).flatMap { it.endpoints }
.mapNotNull { it.transport?.iceUdpTransport }
sessionManager.updateParticipant("p1", transport = transportWithCredentials("initial"))
.also { drain() }
should("not tag a transport signaled before any restart") {
transportsToBridge().last().iceGeneration shouldBe
IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED
}
context("After a restart was relayed") {
sessionManager.restartIce("p1").also { drain() }
sessionManager.updateParticipant("p1", transport = transportWithCredentials("answer"))
.also { drain() }
should("tag the new credentials with the generation of that restart") {
transportsToBridge().last().iceGeneration shouldBe 1
}
context("And the participant repeats the same credentials") {
sessionManager.updateParticipant("p1", transport = transportWithCredentials("answer"))
.also { drain() }
should("not tag them again") {
transportsToBridge().last().iceGeneration shouldBe
IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED
}
}
context("And the participant trickles a candidate") {
sessionManager.updateParticipant("p1", transport = transportWithCandidateOnly())
.also { drain() }
should("not tag it") {
transportsToBridge().last().iceGeneration shouldBe
IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED
}
}
context("And a second restart is relayed") {
sessionManager.restartIce("p1").also { drain() }
sessionManager.updateParticipant("p1", transport = transportWithCredentials("answer-2"))
.also { drain() }
should("tag the next answer with the next generation") {
transportsToBridge().last().iceGeneration shouldBe 2
}
}
}
context("With a participant that tags the answer itself") {
sessionManager.restartIce("p1").also { drain() }
sessionManager.updateParticipant("p1", transport = transportWithGeneration(1)).also { drain() }
should("leave its tag alone") {
transportsToBridge().last().iceGeneration shouldBe 1
}
}
}
context("For an unknown participant") {
sessionManager.restartIce("nonexistent").also { drain() }
should("not send anything to the bridge") {
requestsTo(bridge1).flatMap { it.endpoints }.none { it.transport?.iceRestart == true } shouldBe true
}
should("not relay anything") {
iceRestartedTransports.shouldBeEmpty()
}
}
context("With responses arriving out of order") {
// Colibri2 responses are handled on an IO pool, so two restarts in quick succession can be handled in
// either order. Only the latest generation may be relayed on to the participant.
sessionManager.endpointIceRestarted("p1", transportWithGeneration(2)).also { drain() }
sessionManager.endpointIceRestarted("p1", transportWithGeneration(1)).also { drain() }
sessionManager.endpointIceRestarted("p1", transportWithGeneration(3)).also { drain() }
should("drop the stale generation and relay the rest") {
iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(2, 3)
}
}
context("With a transport that carries no generation") {
// The bridge kept the Agent it already had instead of restarting, so there is nothing to relay.
sessionManager.endpointIceRestarted("p1", transportWithGeneration(2)).also { drain() }
sessionManager.endpointIceRestarted("p1", transportWithGeneration(null)).also { drain() }
should("not relay it") {
iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(2)
}
should("not lose the generation of the last transport that was relayed") {
sessionManager.endpointIceRestarted("p1", transportWithGeneration(1)).also { drain() }
iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(2)
}
}
}
context("Debug state") {
withNewConfig("jicofo.octo.enabled=true") {
allocate("p1", region = "region-jvb1")
allocate("p2", region = "region-jvb2")
sessionManager.debugState shouldNotBe null
}
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.xmpp.extensions.colibri2.Connect
import java.net.URI
class ConnectSpecTest : ShouldSpec() {
init {
val spec = ConnectSpec(
id = "c1",
url = URI("wss://example.com/t"),
type = Connect.Types.TRANSLATOR,
exports = listOf("a-a0", "b-a0"),
requests = listOf("a-a0.es", "b-a0.fr"),
httpHeaders = mapOf("Authorization" to "Bearer x"),
ping = ConnectSpec.Ping(1000, 2000)
)
context("toConnect") {
should("carry all fields and default to no create/expire") {
val connect = spec.toConnect()
connect.id shouldBe "c1"
connect.url shouldBe URI("wss://example.com/t")
connect.type shouldBe Connect.Types.TRANSLATOR
connect.audio shouldBe true
connect.create shouldBe false
connect.expire shouldBe false
connect.getExports() shouldBe listOf("a-a0", "b-a0")
connect.getRequests() shouldBe listOf("a-a0.es", "b-a0.fr")
connect.getHttpHeaders().associate { it.name to it.value } shouldBe mapOf("Authorization" to "Bearer x")
connect.getPing()!!.let {
it.interval shouldBe 1000
it.timeout shouldBe 2000
}
}
should("set the create marker") {
spec.toConnect(create = true).create shouldBe true
}
should("set the expire marker") {
spec.toConnect(expire = true).expire shouldBe true
}
}
context("sameAs") {
should("be true for an identical spec") {
spec.sameAs(spec.copy()) shouldBe true
}
should("ignore source-name ordering") {
val reordered = spec.copy(exports = listOf("b-a0", "a-a0"), requests = listOf("b-a0.fr", "a-a0.es"))
spec.sameAs(reordered) shouldBe true
}
should("differ when the exported source set changes") {
spec.sameAs(spec.copy(exports = listOf("a-a0"))) shouldBe false
}
should("differ when the requested source set changes") {
spec.sameAs(spec.copy(requests = listOf("a-a0.es"))) shouldBe false
}
should("differ when the url changes") {
spec.sameAs(spec.copy(url = URI("wss://example.com/other"))) shouldBe false
}
should("differ when the headers change") {
spec.sameAs(spec.copy(httpHeaders = emptyMap())) shouldBe false
}
should("differ when the ping changes") {
spec.sameAs(spec.copy(ping = ConnectSpec.Ping(1, 2))) shouldBe false
}
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.bridge.colibri
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import org.jitsi.xmpp.extensions.colibri2.Connect
import java.net.URI
class PerSourceTranslatorSpecsTest : ShouldSpec() {
private val url = URI("wss://example.com/t")
init {
context("A sender within the per-connect limit") {
val request = TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.es"))
val specs = perSourceTranslatorSpecs(request, url, maxLanguagesPerConnect = 5, httpHeaders = emptyMap())
should("produce a single connect with all languages") {
specs.size shouldBe 1
specs[0].id shouldBe "translator-aaaaaaaa-a0-0"
specs[0].type shouldBe Connect.Types.TRANSLATOR
specs[0].exports shouldContainExactly listOf("aaaaaaaa-a0")
specs[0].requests shouldContainExactly listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.es")
}
}
context("A sender exceeding the per-connect limit") {
val languages = (1..7).map { "aaaaaaaa-a0.l$it" }
val request = TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", languages)
val specs = perSourceTranslatorSpecs(request, url, maxLanguagesPerConnect = 3, httpHeaders = emptyMap())
should("split into ceil(n / max) connects with stable ids") {
specs.map { it.id } shouldContainExactly listOf(
"translator-aaaaaaaa-a0-0",
"translator-aaaaaaaa-a0-1",
"translator-aaaaaaaa-a0-2"
)
}
should("chunk the languages, each connect exporting the same source") {
specs[0].requests shouldContainExactly languages.subList(0, 3)
specs[1].requests shouldContainExactly languages.subList(3, 6)
specs[2].requests shouldContainExactly languages.subList(6, 7)
specs.forEach { it.exports shouldContainExactly listOf("aaaaaaaa-a0") }
}
}
context("A ping passed through") {
val request = TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.fr"))
val specs = perSourceTranslatorSpecs(
request,
url,
maxLanguagesPerConnect = 1,
httpHeaders = emptyMap(),
ping = ConnectSpec.Ping(10000, 3000)
)
should("set the ping on every connect") {
specs.size shouldBe 2
specs.forEach { it.ping shouldBe ConnectSpec.Ping(10000, 3000) }
}
}
context("No ping by default") {
should("leave ping null") {
perSourceTranslatorSpecs(
TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", listOf("aaaaaaaa-a0.en")),
url,
maxLanguagesPerConnect = 5,
httpHeaders = emptyMap()
).single().ping shouldBe null
}
}
context("A sender with no requested languages") {
should("produce no connects") {
perSourceTranslatorSpecs(
TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", emptyList()),
url,
maxLanguagesPerConnect = 5,
httpHeaders = emptyMap()
) shouldBe emptyList()
}
}
}
}

View File

@@ -0,0 +1,321 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.colibri.WebSocketPacketExtension
import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint
import org.jitsi.xmpp.extensions.colibri2.Colibri2Error
import org.jitsi.xmpp.extensions.colibri2.Colibri2Relay
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
import org.jitsi.xmpp.extensions.colibri2.MediaSource
import org.jitsi.xmpp.extensions.colibri2.Sctp
import org.jitsi.xmpp.extensions.colibri2.Sources
import org.jitsi.xmpp.extensions.colibri2.Transport
import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
import org.jivesoftware.smack.packet.StanzaError.Condition.bad_request
import org.jivesoftware.smack.packet.StanzaError.Condition.conflict
import org.jivesoftware.smack.packet.StanzaError.Condition.feature_not_implemented
import org.jivesoftware.smack.packet.StanzaError.Condition.item_not_found
import java.lang.Exception
import kotlin.jvm.Throws
import kotlin.random.Random
class TestColibri2Server {
private val conferences = mutableMapOf<String, Conference>()
fun stop() = conferences.clear()
fun handleConferenceModifyIq(request: ConferenceModifyIQ): IQ {
val conference: Conference = if (request.create) {
if (conferences.containsKey(request.meetingId)) {
return createError(request, conflict, "Conference already exists")
}
val newConference = Conference(request.meetingId)
conferences[request.meetingId] = newConference
newConference
} else {
conferences[request.meetingId]
?: return createError(request, item_not_found, "Conference not found")
}
return try {
conference.handleRequest(request)
} catch (e: IqProcessingException) {
// Make sure to tag the error as coming from a bridge, otherwise item_not_found may be misiterpreted.
return createError(request, e.condition, e.message, Colibri2Error())
}
}
private fun expireConference(meetingId: String) = conferences.remove(meetingId)
private inner class Conference(val meetingId: String) {
val endpoints = mutableMapOf<String, Endpoint>()
val relays = mutableMapOf<String, Relay>()
@Throws(IqProcessingException::class)
fun handleRequest(request: ConferenceModifyIQ): IQ {
val responseBuilder =
ConferenceModifiedIQ.builder(ConferenceModifiedIQ.Builder.createResponse(request))
if (request.expire) {
expireConference(meetingId)
return responseBuilder.build()
}
if (request.create) {
responseBuilder.setSources(buildFeedbackSources(Random.nextLong(), Random.nextLong()))
}
request.endpoints.forEach { responseBuilder.addEndpoint(handleEndpoint(it)) }
request.relays.forEach { responseBuilder.addRelay(handleRelay(it)) }
if (!request.create && endpoints.isEmpty()) {
expireConference(meetingId)
}
return responseBuilder.build()
}
private fun expireEndpoint(id: String) = endpoints.remove(id)
private fun handleEndpoint(c2endpoint: Colibri2Endpoint): Colibri2Endpoint {
val respBuilder = Colibri2Endpoint.getBuilder().apply { setId(c2endpoint.id) }
if (c2endpoint.expire) {
expireEndpoint(c2endpoint.id)
respBuilder.setExpire(true)
return respBuilder.build()
}
val endpoint = if (c2endpoint.create) {
if (endpoints.containsKey(c2endpoint.id)) {
throw IqProcessingException(conflict, "Endpoint with ID ${c2endpoint.id} already exists")
}
val transport = c2endpoint.transport ?: throw IqProcessingException(
bad_request,
"Attempt to create endpoint ${c2endpoint.id} with no <transport>"
)
val newEndpoint = Endpoint(c2endpoint.id).apply {
transport.sctp?.let { sctp ->
if (sctp.role != null && sctp.role != Sctp.Role.SERVER) {
throw IqProcessingException(
feature_not_implemented,
"Unsupported SCTP role: ${sctp.role}"
)
}
if (sctp.port != null) {
throw IqProcessingException(bad_request, "Specific SCTP port requested, not supported.")
}
useSctp = true
}
}
endpoints[c2endpoint.id] = newEndpoint
newEndpoint
} else {
endpoints[c2endpoint.id] ?: throw IqProcessingException(
item_not_found,
"Unknown endpoint ${c2endpoint.id}"
)
}
// c2endpoint.transport?.iceUdpTransport?.let { endpoint.setTransportInfo(it) }
if (c2endpoint.create) {
val transBuilder = Transport.getBuilder()
transBuilder.setIceUdpExtension(endpoint.describeTransport())
if (c2endpoint.transport?.sctp != null) {
transBuilder.setSctp(
Sctp.Builder()
.setPort(5000)
.setRole(Sctp.Role.SERVER)
.build()
)
}
respBuilder.setTransport(transBuilder.build())
} else if (c2endpoint.transport?.iceRestart == true) {
// Model the bridge's in-place ICE restart: it creates a new agent with fresh credentials, tags them
// with the next generation, and returns them in the response.
endpoint.iceGeneration++
respBuilder.setTransport(
Transport.getBuilder().setIceUdpExtension(endpoint.describeTransport()).build()
)
}
c2endpoint.forceMute?.let {
endpoint.forceMuteAudio = it.audio
endpoint.forceMuteVideo = it.video
}
return respBuilder.build()
}
private fun handleRelay(c2relay: Colibri2Relay): Colibri2Relay {
val respBuilder = Colibri2Relay.getBuilder().apply { setId(c2relay.id) }
if (c2relay.expire) {
if (relays.remove(c2relay.id) == null) {
throw IqProcessingException(item_not_found, "Unknown relay ${c2relay.id}")
}
respBuilder.setExpire(true)
return respBuilder.build()
}
val relay = if (c2relay.create) {
if (relays.containsKey(c2relay.id)) {
throw IqProcessingException(conflict, "Relay with ID ${c2relay.id} already exists")
}
val transport = c2relay.transport ?: throw IqProcessingException(
bad_request,
"Attempt to create relay ${c2relay.id} with no <transport>"
)
val newRelay = Relay(c2relay.id, useSctp = transport.sctp != null)
relays[c2relay.id] = newRelay
newRelay
} else {
relays[c2relay.id] ?: throw IqProcessingException(
item_not_found,
"Unknown relay ${c2relay.id}"
)
}
c2relay.endpoints?.endpoints?.forEach { relayEndpoint ->
if (relayEndpoint.expire) {
relay.endpoints.remove(relayEndpoint.id)
} else {
relay.endpoints[relayEndpoint.id] = relayEndpoint
}
}
if (c2relay.create) {
respBuilder.setTransport(
Transport.getBuilder().apply {
setIceUdpExtension(relay.describeTransport())
}.build()
)
}
return respBuilder.build()
}
private inner class Relay(val id: String, val useSctp: Boolean) {
/** Remote endpoints that have been set on this relay, by ID. */
val endpoints = mutableMapOf<String, Colibri2Endpoint>()
fun describeTransport() = IceUdpTransportPacketExtension().apply {
password = "password-$meetingId-relay-$id"
ufrag = "ufrag-$meetingId-relay-$id"
addChildExtension(
DtlsFingerprintPacketExtension().apply {
hash = "sha-256"
setup = "actpass"
text = "AC:58:2D:03:40:89:87:30:6C:25:2C:50:17:5C:5C:2E:" +
"1F:A1:19:19:4D:74:A5:37:35:22:6E:8E:DF:55:13:8E"
}
)
if (!useSctp) {
// The bridge advertises a websocket unless SCTP is in use.
addChildExtension(WebSocketPacketExtension("wss://example.com/colibri-relay-ws/$meetingId/$id"))
}
}
}
private inner class Endpoint(val id: String) {
var useSctp = false
var forceMuteAudio = false
var forceMuteVideo = false
/** Incremented on each in-place ICE restart, i.e. each time the bridge rotates its credentials. */
var iceGeneration = 0
fun describeTransport(): IceUdpTransportPacketExtension {
// Note: bound outside the apply{} below, where `iceGeneration` would resolve to the transport's own
// property rather than this endpoint's.
val generation = iceGeneration
return IceUdpTransportPacketExtension().apply {
password = "password-$meetingId-$id-$generation"
ufrag = "ufrag-$meetingId-$id-$generation"
if (generation > 0) {
setIceGeneration(generation)
}
// TODO add some candidates?
addChildExtension(
DtlsFingerprintPacketExtension().apply {
hash = "sha-256"
text = "AC:58:2D:03:40:89:87:30:6C:25:2C:50:17:5C:5C:2E:" +
"1F:A1:19:19:4D:74:A5:37:35:22:6E:8E:DF:55:13:8E"
}
)
}
}
}
}
}
private class IqProcessingException(
val condition: StanzaError.Condition,
message: String
) : Exception(message) {
override fun toString() = "$condition $message"
}
private fun buildFeedbackSources(localAudioSsrc: Long, localVideoSsrc: Long): Sources = Sources.getBuilder().apply {
addMediaSource(
MediaSource.getBuilder()
.setType(MediaType.AUDIO)
.setId("jvb-a0")
.addSource(
SourcePacketExtension().apply {
ssrc = localAudioSsrc
name = "jvb-a0"
}
)
.build()
)
addMediaSource(
MediaSource.getBuilder()
.setType(MediaType.VIDEO)
.setId("jvb-v0")
.addSource(
SourcePacketExtension().apply {
ssrc = localVideoSsrc
name = "jvb-v0"
}
)
.build()
)
}.build()
fun createError(
request: IQ,
errorCondition: StanzaError.Condition,
errorMessage: String? = null,
extension: ExtensionElement? = null
): IQ {
val error = StanzaError.getBuilder(errorCondition)
errorMessage?.let { error.setDescriptiveEnText(it) }
extension?.let {
error.setExtensions(listOf(it))
}
return IQ.createErrorResponse(request, error.build())
}