forked from public-mirrors/HuskSync
Introduce new lockstep syncing system, modularize sync modes (#178)
* Start work on modular sync systems * Add experimental lockstep sync system, close #69 * Refactor RedisMessageType enum * Fixup lockstep syncing * Bump to 3.1 * Update docs with details about the new Sync Modes * Sync mode config key is `mode` instead of `type` * Add server to data snapshot overview * API: Add API for setting data syncers * Fixup weird statistic matching logicfeat/data-edit-commands
parent
03ca335293
commit
cae17f6e68
@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of HuskSync, licensed under the Apache License 2.0.
|
||||||
|
*
|
||||||
|
* Copyright (c) William278 <will27528@gmail.com>
|
||||||
|
* Copyright (c) contributors
|
||||||
|
*
|
||||||
|
* 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 net.william278.husksync.config;
|
||||||
|
|
||||||
|
import net.william278.annotaml.YamlFile;
|
||||||
|
import net.william278.annotaml.YamlKey;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a server on a proxied network.
|
||||||
|
*/
|
||||||
|
@YamlFile(header = """
|
||||||
|
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||||
|
┃ HuskSync Server ID config ┃
|
||||||
|
┃ Developed by William278 ┃
|
||||||
|
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||||
|
┣╸ This file should contain the ID of this server as defined in your proxy config.
|
||||||
|
┗╸ If you join it using /server alpha, then set it to 'alpha' (case-sensitive)""")
|
||||||
|
public class Server {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default server identifier.
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
public static String getDefaultServerName() {
|
||||||
|
try {
|
||||||
|
final Path serverDirectory = Path.of(System.getProperty("user.dir"));
|
||||||
|
return serverDirectory.getFileName().toString().trim();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "server";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@YamlKey("name")
|
||||||
|
private String serverName = getDefaultServerName();
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private Server() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(@NotNull Object other) {
|
||||||
|
// If the name of this server matches another, the servers are the same.
|
||||||
|
if (other instanceof Server server) {
|
||||||
|
return server.getName().equalsIgnoreCase(this.getName());
|
||||||
|
}
|
||||||
|
return super.equals(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy-defined name of this server.
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
public String getName() {
|
||||||
|
return serverName;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of HuskSync, licensed under the Apache License 2.0.
|
|
||||||
*
|
|
||||||
* Copyright (c) William278 <will27528@gmail.com>
|
|
||||||
* Copyright (c) contributors
|
|
||||||
*
|
|
||||||
* 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 net.william278.husksync.redis;
|
|
||||||
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public enum RedisMessageType {
|
|
||||||
|
|
||||||
UPDATE_USER_DATA,
|
|
||||||
REQUEST_USER_DATA,
|
|
||||||
RETURN_USER_DATA;
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
public String getMessageChannel(@NotNull String clusterId) {
|
|
||||||
return String.format(
|
|
||||||
"%s:%s:%s",
|
|
||||||
RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH),
|
|
||||||
clusterId.toLowerCase(Locale.ENGLISH),
|
|
||||||
name().toLowerCase(Locale.ENGLISH)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Optional<RedisMessageType> getTypeFromChannel(@NotNull String channel, @NotNull String clusterId) {
|
|
||||||
return Arrays.stream(values())
|
|
||||||
.filter(messageType -> messageType.getMessageChannel(clusterId).equalsIgnoreCase(channel))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of HuskSync, licensed under the Apache License 2.0.
|
||||||
|
*
|
||||||
|
* Copyright (c) William278 <will27528@gmail.com>
|
||||||
|
* Copyright (c) contributors
|
||||||
|
*
|
||||||
|
* 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 net.william278.husksync.sync;
|
||||||
|
|
||||||
|
import net.william278.husksync.HuskSync;
|
||||||
|
import net.william278.husksync.api.HuskSyncAPI;
|
||||||
|
import net.william278.husksync.data.DataSnapshot;
|
||||||
|
import net.william278.husksync.user.OnlineUser;
|
||||||
|
import net.william278.husksync.util.Task;
|
||||||
|
import org.jetbrains.annotations.ApiStatus;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the synchronization of data when a player changes servers or logs in
|
||||||
|
*
|
||||||
|
* @since 3.1
|
||||||
|
*/
|
||||||
|
public abstract class DataSyncer {
|
||||||
|
private static final long BASE_LISTEN_ATTEMPTS = 16;
|
||||||
|
private static final long LISTEN_DELAY = 10;
|
||||||
|
|
||||||
|
protected final HuskSync plugin;
|
||||||
|
private final long maxListenAttempts;
|
||||||
|
|
||||||
|
@ApiStatus.Internal
|
||||||
|
protected DataSyncer(@NotNull HuskSync plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.maxListenAttempts = getMaxListenAttempts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-exposed constructor for a {@link DataSyncer}
|
||||||
|
*
|
||||||
|
* @param api instance of the {@link HuskSyncAPI}
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public DataSyncer(@NotNull HuskSyncAPI api) {
|
||||||
|
this(api.getPlugin());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the plugin is enabled
|
||||||
|
*/
|
||||||
|
public void initialize() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the plugin is disabled
|
||||||
|
*/
|
||||||
|
public void terminate() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a user's data should be fetched and applied to them
|
||||||
|
*
|
||||||
|
* @param user the user to fetch data for
|
||||||
|
*/
|
||||||
|
public abstract void setUserData(@NotNull OnlineUser user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a user's data should be serialized and saved
|
||||||
|
*
|
||||||
|
* @param user the user to save
|
||||||
|
*/
|
||||||
|
public abstract void saveUserData(@NotNull OnlineUser user);
|
||||||
|
|
||||||
|
// Calculates the max attempts the system should listen for user data for based on the latency value
|
||||||
|
private long getMaxListenAttempts() {
|
||||||
|
return BASE_LISTEN_ATTEMPTS + (
|
||||||
|
(Math.max(100, plugin.getSettings().getNetworkLatencyMilliseconds()) / 1000) * 20 / LISTEN_DELAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a user's data from the database, or set them as a new user
|
||||||
|
@ApiStatus.Internal
|
||||||
|
protected void setUserFromDatabase(@NotNull OnlineUser user) {
|
||||||
|
plugin.getDatabase().getLatestSnapshot(user).ifPresentOrElse(
|
||||||
|
snapshot -> user.applySnapshot(snapshot, DataSnapshot.UpdateCause.SYNCHRONIZED),
|
||||||
|
() -> user.completeSync(true, DataSnapshot.UpdateCause.NEW_USER, plugin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuously listen for data from Redis
|
||||||
|
@ApiStatus.Internal
|
||||||
|
protected void listenForRedisData(@NotNull OnlineUser user, @NotNull Supplier<Boolean> completionSupplier) {
|
||||||
|
final AtomicLong timesRun = new AtomicLong(0L);
|
||||||
|
final AtomicReference<Task.Repeating> task = new AtomicReference<>();
|
||||||
|
final Runnable runnable = () -> {
|
||||||
|
if (user.isOffline()) {
|
||||||
|
task.get().cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (plugin.isDisabling() || timesRun.getAndIncrement() > maxListenAttempts) {
|
||||||
|
task.get().cancel();
|
||||||
|
setUserFromDatabase(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionSupplier.get()) {
|
||||||
|
task.get().cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
task.set(plugin.getRepeatingTask(runnable, LISTEN_DELAY));
|
||||||
|
task.get().run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the different available default modes of {@link DataSyncer}
|
||||||
|
*
|
||||||
|
* @since 3.1
|
||||||
|
*/
|
||||||
|
public enum Mode {
|
||||||
|
DELAY(DelayDataSyncer::new),
|
||||||
|
LOCKSTEP(LockstepDataSyncer::new);
|
||||||
|
|
||||||
|
private final Function<HuskSync, ? extends DataSyncer> supplier;
|
||||||
|
|
||||||
|
Mode(@NotNull Function<HuskSync, ? extends DataSyncer> supplier) {
|
||||||
|
this.supplier = supplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
public DataSyncer create(@NotNull HuskSync plugin) {
|
||||||
|
return supplier.apply(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of HuskSync, licensed under the Apache License 2.0.
|
||||||
|
*
|
||||||
|
* Copyright (c) William278 <will27528@gmail.com>
|
||||||
|
* Copyright (c) contributors
|
||||||
|
*
|
||||||
|
* 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 net.william278.husksync.sync;
|
||||||
|
|
||||||
|
import net.william278.husksync.HuskSync;
|
||||||
|
import net.william278.husksync.data.DataSnapshot;
|
||||||
|
import net.william278.husksync.user.OnlineUser;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data syncer which applies a network delay before checking the presence of user data
|
||||||
|
*/
|
||||||
|
public class DelayDataSyncer extends DataSyncer {
|
||||||
|
|
||||||
|
public DelayDataSyncer(@NotNull HuskSync plugin) {
|
||||||
|
super(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setUserData(@NotNull OnlineUser user) {
|
||||||
|
plugin.runAsyncDelayed(
|
||||||
|
() -> {
|
||||||
|
// Fetch from the database if the user isn't changing servers
|
||||||
|
if (!plugin.getRedisManager().getUserServerSwitch(user)) {
|
||||||
|
this.setUserFromDatabase(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for the data to be updated
|
||||||
|
this.listenForRedisData(
|
||||||
|
user,
|
||||||
|
() -> plugin.getRedisManager().getUserData(user).map(data -> {
|
||||||
|
user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED);
|
||||||
|
return true;
|
||||||
|
}).orElse(false)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Math.max(0, plugin.getSettings().getNetworkLatencyMilliseconds() / 50L)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveUserData(@NotNull OnlineUser user) {
|
||||||
|
plugin.runAsync(() -> {
|
||||||
|
plugin.getRedisManager().setUserServerSwitch(user);
|
||||||
|
final DataSnapshot.Packed data = user.createSnapshot(DataSnapshot.SaveCause.DISCONNECT);
|
||||||
|
plugin.getRedisManager().setUserData(user, data);
|
||||||
|
plugin.getDatabase().addSnapshot(user, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of HuskSync, licensed under the Apache License 2.0.
|
||||||
|
*
|
||||||
|
* Copyright (c) William278 <will27528@gmail.com>
|
||||||
|
* Copyright (c) contributors
|
||||||
|
*
|
||||||
|
* 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 net.william278.husksync.sync;
|
||||||
|
|
||||||
|
import net.william278.husksync.HuskSync;
|
||||||
|
import net.william278.husksync.data.DataSnapshot;
|
||||||
|
import net.william278.husksync.user.OnlineUser;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public class LockstepDataSyncer extends DataSyncer {
|
||||||
|
|
||||||
|
public LockstepDataSyncer(@NotNull HuskSync plugin) {
|
||||||
|
super(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() {
|
||||||
|
plugin.getRedisManager().clearUsersCheckedOutOnServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void terminate() {
|
||||||
|
plugin.getRedisManager().clearUsersCheckedOutOnServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume their data when they are checked in
|
||||||
|
@Override
|
||||||
|
public void setUserData(@NotNull OnlineUser user) {
|
||||||
|
this.listenForRedisData(user, () -> {
|
||||||
|
if (plugin.getRedisManager().getUserCheckedOut(user).isEmpty()) {
|
||||||
|
plugin.getRedisManager().setUserCheckedOut(user, true);
|
||||||
|
plugin.getRedisManager().getUserData(user).ifPresentOrElse(
|
||||||
|
data -> user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED),
|
||||||
|
() -> this.setUserFromDatabase(user)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveUserData(@NotNull OnlineUser user) {
|
||||||
|
plugin.runAsync(() -> {
|
||||||
|
final DataSnapshot.Packed data = user.createSnapshot(DataSnapshot.SaveCause.DISCONNECT);
|
||||||
|
plugin.getRedisManager().setUserData(user, data);
|
||||||
|
plugin.getRedisManager().setUserCheckedOut(user, false);
|
||||||
|
plugin.getDatabase().addSnapshot(user, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
HuskSync offers two built-in **synchronization modes**. These sync modes change the way data is synced between servers. This page details the two sync modes available and how they work.
|
||||||
|
|
||||||
|
* The `DELAY` sync mode is the default sync mode, that use the `network_latency_miliseconds` value to apply a delay before listening to Redis data
|
||||||
|
* The `LOCKSTEP` sync mode uses a data checkout system to ensure that all servers are in sync regardless of network latency or tick rate fluctuations. This mode was introduced in HuskSync v3.1
|
||||||
|
|
||||||
|
You can change which sync mode you are using by editing the `sync_mode` setting under `synchronization` in `config.yml`.
|
||||||
|
|
||||||
|
> **Warning:** Please note that you must use the same sync mode on all servers (at least within a cluster).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Changing the sync mode (config.yml)</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
synchronization:
|
||||||
|
# The mode of data synchronization to use (DELAY or LOCKSTEP). DELAY should be fine for most networks. Docs: https://william278.net/docs/husksync/sync-modes
|
||||||
|
mode: DELAY
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Delay
|
||||||
|
![Delay diagram](https://raw.githubusercontent.com/WiIIiam278/HuskSync/master/images/system-diagram.png)
|
||||||
|
|
||||||
|
The `DELAY` sync mode works as described below:
|
||||||
|
* When a user disconnects from a server, a `SERVER_SWITCH` key is immediately set on Redis, followed by a `DATA_UPDATE` key which contains the user's packed and serialized Data Snapshot.
|
||||||
|
* When the user connects to a server, they are marked as locked (unable to break blocks, use containers, etc.)
|
||||||
|
* The server asynchronously waits for the `network_latency_miliseconds` value (default: 500ms) to allow the source server time to serialize & set their key.
|
||||||
|
* After waiting, the server checks for the `SERVER_SWITCH` key.
|
||||||
|
* If present, it will continuously attempt to read for a `DATA_UPDATE` key; when read, their data will be set from the snapshot deserialized from Redis.
|
||||||
|
* If not present, their data will be pulled from the database (as though they joined the network)
|
||||||
|
|
||||||
|
`DELAY` has been the default sync mode since HuskSync v2.0. In HuskSync v3.1, `LOCKSTEP` was introduced. Since the delay mode has been tested and deployed for the longest, it is still the default, though note this may change in the future.
|
||||||
|
|
||||||
|
However, if your network has a fluctuating tick rate or significant latency (especially if you have servers on different hardware/locations), you may wish to use `LOCKSTEP` instead for a more reliable sync system.
|
||||||
|
|
||||||
|
## Lockstep
|
||||||
|
The `LOCKSTEP` sync mode works as described below:
|
||||||
|
* When a user connects to a server, the server will continuously asynchronously check if a `DATA_CHECKOUT` key is present.
|
||||||
|
* If, or when, the key is not present, the plugin will set a new `DATA_CHECKOUT` key.
|
||||||
|
* After this, the plugin will check Redis for the presence of a `DATA_UPDATE` key.
|
||||||
|
* If a `DATA_UPDATE` key is present, the user's data will be set from the snapshot deserialized from Redis contained within that key.
|
||||||
|
* Otherwise, their data will be pulled from the database.
|
||||||
|
* When a user disconnects from a server, their data is serialized and set to Redis with a `DATA_UPDATE` key. After this key has been set, the user's current `DATA_CHECKOUT` key will be removed from Redis.
|
||||||
|
|
||||||
|
Additionally, note that `DATA_CHECKOUT` keys are set with the server ID of the server which "checked out" the data (taken from the `server.yml` config file). On both shutdown and startup, the plugin will clear all `DATA_CHECKOUT` keys for the current server ID (to prevent stale keys in the event of a server crash for instance)
|
Loading…
Reference in New Issue