snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
This commit is contained in:
415
jicofo-selector/pom.xml
Normal file
415
jicofo-selector/pom.xml
Normal file
@@ -0,0 +1,415 @@
|
||||
<?xml version="1.0"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.jitsi</groupId>
|
||||
<artifactId>jicofo-parent</artifactId>
|
||||
<version>1.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>jicofo-selector</artifactId>
|
||||
<version>1.1-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>jicofo-selector</name>
|
||||
<description>JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences.</description>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicofo-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.igniterealtime.smack</groupId>
|
||||
<artifactId>smack-tcp</artifactId>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/org.jetbrains/annotations -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<!-- org.jitsi -->
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-metrics</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-metaconfig</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-utils</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-xmpp-extensions</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-jdk14</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.github.spotbugs/spotbugs-annotations -->
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-annotations</artifactId>
|
||||
</dependency>
|
||||
<!-- runtime -->
|
||||
<dependency>
|
||||
<groupId>io.sentry</groupId>
|
||||
<artifactId>sentry</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- test -->
|
||||
<dependency>
|
||||
<groupId>org.xmlunit</groupId>
|
||||
<artifactId>xmlunit-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-runner-junit5-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-assertions-core-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-property-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.platform</groupId>
|
||||
<artifactId>junit-platform-launcher</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-test-kotlin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicofo-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>test-jar</type>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.mockk</groupId>
|
||||
<artifactId>mockk-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
|
||||
<sourceDir>${project.basedir}/src/main/java</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
|
||||
<sourceDir>${project.basedir}/src/test/java</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<jvmTarget>17</jvmTarget>
|
||||
<args>
|
||||
<arg>-Xallow-result-return-type</arg>
|
||||
</args>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
<executions>
|
||||
<!-- Replacing default-compile as it is treated specially by maven -->
|
||||
<execution>
|
||||
<id>default-compile</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
<!-- Replacing default-testCompile as it is treated specially by maven -->
|
||||
<execution>
|
||||
<id>default-testCompile</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>java-compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>java-test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>testCompile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<source>17</source>
|
||||
<target>17</target>
|
||||
<compilerArgs>
|
||||
<arg>-Xlint:all,-serial</arg>
|
||||
</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.config.file>${project.parent.basedir}/lib/logging.properties</java.util.logging.config.file>
|
||||
<kotest.framework.classpath.scanning.autoscan.disable>true</kotest.framework.classpath.scanning.autoscan.disable>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<!-- Instructs the plugin to add the current package version in the jar manifest
|
||||
So we can access it runtime with: .class.getPackage().getImplementationVersion()
|
||||
-->
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<!-- Publish test utilities (TestColibri2Server) for use in the jicofo module. -->
|
||||
<id>test-jar</id>
|
||||
<goals>
|
||||
<goal>test-jar</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**/*Test.class</exclude>
|
||||
<exclude>**/*Test$*.class</exclude>
|
||||
<exclude>**/JicofoSelectorKotestProjectConfig.class</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
</configuration>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>9.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>${spotbugs-maven-plugin.version}</version>
|
||||
<configuration>
|
||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||
<includeTests>true</includeTests>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs</artifactId>
|
||||
<version>${spotbugs.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<!-- ktlint: check runs in the verify phase; auto-format with "mvn exec:exec@ktlint-format" -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.pinterest.ktlint</groupId>
|
||||
<artifactId>ktlint-cli</artifactId>
|
||||
<version>${ktlint.version}</version>
|
||||
<classifier>all</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>ktlint-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>java</executable>
|
||||
<includePluginDependencies>true</includePluginDependencies>
|
||||
<arguments>
|
||||
<argument>-classpath</argument>
|
||||
<classpath>
|
||||
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
|
||||
</classpath>
|
||||
<argument>com.pinterest.ktlint.Main</argument>
|
||||
<argument>--relative</argument>
|
||||
<argument>src/main/kotlin/**/*.kt</argument>
|
||||
<argument>src/test/kotlin/**/*.kt</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>ktlint-format</id>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>java</executable>
|
||||
<includePluginDependencies>true</includePluginDependencies>
|
||||
<arguments>
|
||||
<argument>-classpath</argument>
|
||||
<classpath>
|
||||
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
|
||||
</classpath>
|
||||
<argument>com.pinterest.ktlint.Main</argument>
|
||||
<argument>--format</argument>
|
||||
<argument>--relative</argument>
|
||||
<argument>src/main/kotlin/**/*.kt</argument>
|
||||
<argument>src/test/kotlin/**/*.kt</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>jitsi-maven-repository-releases</id>
|
||||
<layout>default</layout>
|
||||
<name>Jitsi Maven Repository (Releases)</name>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>jitsi-maven-repository-snapshots</id>
|
||||
<layout>default</layout>
|
||||
<name>Jitsi Maven Repository (Snapshots)</name>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>coverage</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<!-- attached to Maven test phase -->
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
63
jicofo-selector/spotbugs-exclude.xml
Normal file
63
jicofo-selector/spotbugs-exclude.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<FindBugsFilter>
|
||||
|
||||
<Match>
|
||||
<And>
|
||||
<Source name="~.*\.*.kt" />
|
||||
<Or>
|
||||
<Bug pattern="REC_CATCH_EXCEPTION"/>
|
||||
<!-- This seems to get triggered by certain kotlin constructs though they look safe -->
|
||||
<Bug pattern="BC_BAD_CAST_TO_ABSTRACT_COLLECTION"/>
|
||||
<!-- False positive -->
|
||||
<Bug pattern="BC_VACUOUS_INSTANCEOF"/>
|
||||
<!-- Serialization -->
|
||||
<Bug pattern="SE_BAD_FIELD"/>
|
||||
<!-- Serialization -->
|
||||
<Bug pattern="SE_BAD_FIELD_STORE"/>
|
||||
<!-- False positives with lazy vals -->
|
||||
<Bug pattern="NP_NULL_ON_SOME_PATH"/>
|
||||
<!-- False positives with kotlin 1.5 -->
|
||||
<Bug pattern="NP_PARAMETER_MUST_BE_NONNULL_BUT_MARKED_AS_NULLABLE"/>
|
||||
<!-- False positive -->
|
||||
<Bug pattern="NP_NONNULL_RETURN_VIOLATION"/>
|
||||
<!-- False positive with kotlin's "when" -->
|
||||
<Bug pattern="SF_SWITCH_NO_DEFAULT"/>
|
||||
<!-- False positives with ?. chains. -->
|
||||
<Bug pattern="RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"/>
|
||||
<And>
|
||||
<!-- Spotbugs doesn't recognize that a value can't be null, and using @SuppressFBWarnings doesn't work. -->
|
||||
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
|
||||
<Source name="MuteIqHandler.kt"/>
|
||||
</And>
|
||||
<!-- False positives with kotlin 1.6. -->
|
||||
<Bug pattern="NP_NONNULL_PARAM_VIOLATION"/>
|
||||
<Bug pattern="SA_LOCAL_SELF_ASSIGNMENT"/>
|
||||
</Or>
|
||||
</And>
|
||||
</Match>
|
||||
|
||||
<Match>
|
||||
<Or>
|
||||
<!-- Exposure of internal representation is not a concern. -->
|
||||
<Bug pattern="EI_EXPOSE_REP"/>
|
||||
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||
<!-- False positives in java code for an unknown reason. -->
|
||||
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<Match>
|
||||
<!-- Tests have a valid reason to ignore return values (expecting exceptions, etc). -->
|
||||
<And>
|
||||
<Or>
|
||||
<Bug pattern="RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"/>
|
||||
</Or>
|
||||
<Or>
|
||||
<Source name="~.*\.*Test.kt" />
|
||||
<Source name="~.*\.*Test.java" />
|
||||
</Or>
|
||||
</And>
|
||||
</Match>
|
||||
|
||||
|
||||
</FindBugsFilter>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
181
jicofo-selector/src/main/resources/reference.conf
Normal file
181
jicofo-selector/src/main/resources/reference.conf
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user