mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-20 11:42:49 +00:00
feat: convert 3D scenery models on worker threads with deferred replies
The dashboard's web server answers one request at a time, so converting a big .nif (glom files up to tens of MB) held up every other request, flairs included. - dWeb: Web::Defer hands a request to another thread; the reply is sent from the web thread on its next poll (DeferredQueue). A client that leaves first cancels it and the late reply is dropped. The synchronous route API is unchanged. - Web::Shutdown closes connections while the state their close events touch is still alive; the destructor no longer runs handlers during static destruction (stopping the dashboard aborted in ~WSClient). - WorkerPool: priority lanes, with one thread only for urgent work (flairs, small models, textures), and limited background work. - Scenery: mesh and texture routes (and the showcase's) convert on the pool; thread-safe memory and disk caches, one conversion per model at a time with waiters sharing it; zones are converted ahead onto the disk cache while viewed. - Setting scenery_workers (0: half the cores, 2 to 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -654,7 +654,9 @@ int main(int argc, char** argv) {
|
||||
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
// Cleanup: the conversion threads first (they answer deferred requests), then the web server's connections
|
||||
Scenery::Shutdown();
|
||||
Game::web.Shutdown();
|
||||
Inspector::Shutdown();
|
||||
EmailService::Shutdown();
|
||||
ModeratorHelper::Shutdown();
|
||||
|
||||
@@ -42,6 +42,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"WorldView.cpp"
|
||||
"NifFile.cpp"
|
||||
"Scenery.cpp"
|
||||
"WorkerPool.cpp"
|
||||
"AccountModeration.cpp"
|
||||
"ModerationTools.cpp"
|
||||
"ModeratorHelper.cpp"
|
||||
|
||||
@@ -47,8 +47,8 @@ namespace {
|
||||
* Where a normalized (lowercase) res/ path is on disk. Unpacked clients keep their original mixed case, so on
|
||||
* case-sensitive file systems each part is matched ignoring case.
|
||||
*/
|
||||
std::filesystem::path ResolveRes(const std::string& normalized) {
|
||||
auto current = ClientRes();
|
||||
std::filesystem::path ResolveResIn(const std::filesystem::path& res, const std::string& normalized) {
|
||||
auto current = res;
|
||||
std::error_code ec;
|
||||
for (const auto& part : GeneralUtils::SplitString(normalized, '/')) {
|
||||
if (part.empty()) continue;
|
||||
@@ -72,6 +72,10 @@ namespace {
|
||||
return current;
|
||||
}
|
||||
|
||||
std::filesystem::path ResolveRes(const std::string& normalized) {
|
||||
return ResolveResIn(ClientRes(), normalized);
|
||||
}
|
||||
|
||||
std::optional<std::string> ReadFile(const std::filesystem::path& path) {
|
||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||
if (!file) return std::nullopt;
|
||||
@@ -338,6 +342,19 @@ namespace ClientAssets {
|
||||
return path;
|
||||
}
|
||||
|
||||
std::filesystem::path ResFolder() {
|
||||
return ClientRes();
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> ResolveResFile(const std::string& relativePath, const std::filesystem::path& res) {
|
||||
const auto normalized = NormalizeAssetPath(relativePath);
|
||||
if (res.empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
|
||||
auto path = ResolveResIn(res, normalized);
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_regular_file(path, ec)) return std::nullopt;
|
||||
return path;
|
||||
}
|
||||
|
||||
std::optional<std::string> FindResFile(const std::string& folder, const std::string& fileName) {
|
||||
const auto normalized = NormalizeAssetPath(folder);
|
||||
if (ClientRes().empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
|
||||
|
||||
@@ -37,6 +37,12 @@ namespace ClientAssets {
|
||||
// Where a file inside res/ is on disk (matched ignoring case), if it exists
|
||||
std::optional<std::filesystem::path> ResolveResFile(const std::string& relativePath);
|
||||
|
||||
// The client's res/ folder (empty when client_location isn't set)
|
||||
std::filesystem::path ResFolder();
|
||||
|
||||
// ResolveResFile inside a res/ folder from ResFolder: reads no settings, so worker threads may call it
|
||||
std::optional<std::filesystem::path> ResolveResFile(const std::string& relativePath, const std::filesystem::path& res);
|
||||
|
||||
// The res/-relative path of a file called `fileName` (ignoring case) anywhere under res/<folder>
|
||||
std::optional<std::string> FindResFile(const std::string& folder, const std::string& fileName);
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
@@ -16,6 +20,8 @@
|
||||
#include "ReportRoutes.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "TtlCache.h"
|
||||
#include "Web.h"
|
||||
#include "WorkerPool.h"
|
||||
#include "WorldScene.h"
|
||||
#include "ZonePaths.h"
|
||||
|
||||
@@ -194,6 +200,9 @@ namespace {
|
||||
std::map<std::string, size_t> index; // res path -> its index in assets
|
||||
std::string json;
|
||||
std::optional<std::string> flairs; // the flairs' manifest, built when first asked for (their models join assets)
|
||||
std::unordered_set<std::string> flairModels; // res paths of the flairs' models, converted ahead of others
|
||||
bool warmedScenery{}; // WarmUp queued the scenery's models
|
||||
bool warmedFlairs{}; // and the flairs'
|
||||
|
||||
size_t IndexOf(const std::string& path) {
|
||||
const auto [it, added] = index.try_emplace(path, assets.size());
|
||||
@@ -277,6 +286,7 @@ namespace {
|
||||
const auto model = models.find(flair.id);
|
||||
if (model == models.end() || model->second.empty() || !std::isfinite(flair.position.x)) continue;
|
||||
assetOf.push_back(scenery.IndexOf(model->second));
|
||||
scenery.flairModels.insert(model->second);
|
||||
for (const auto value : { flair.position.x, flair.position.y, flair.position.z }) positions.push_back(Round(value, 100.0));
|
||||
// Radians about x, y and z, applied in that order
|
||||
const double c1 = std::cos(flair.rotation.x / 2), c2 = std::cos(flair.rotation.y / 2), c3 = std::cos(flair.rotation.z / 2);
|
||||
@@ -316,74 +326,170 @@ namespace {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Keeps the disk cache under DISK_CACHE_BYTES by removing the least recently written files
|
||||
void StoreOnDisk(const std::filesystem::path& target, const std::string& data) {
|
||||
static std::optional<uintmax_t> total;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(CACHE_DIR, ec);
|
||||
if (!total) {
|
||||
total = 0;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(CACHE_DIR, ec)) *total += entry.is_regular_file(ec) ? entry.file_size(ec) : 0;
|
||||
/**
|
||||
* The converted models on disk (CACHE_DIR), kept under DISK_CACHE_BYTES by removing the least recently written
|
||||
* files. Any thread.
|
||||
*/
|
||||
class DiskCache {
|
||||
public:
|
||||
void Store(const std::filesystem::path& target, const std::string& data) {
|
||||
std::lock_guard lock(m_Mutex);
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(CACHE_DIR, ec);
|
||||
CountLocked();
|
||||
if (m_Total + data.size() > DISK_CACHE_BYTES) {
|
||||
std::vector<std::pair<std::filesystem::file_time_type, std::filesystem::path>> files;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(CACHE_DIR, ec)) {
|
||||
if (entry.path().extension() == ".bin") files.emplace_back(entry.last_write_time(ec), entry.path());
|
||||
}
|
||||
std::sort(files.begin(), files.end());
|
||||
for (const auto& [time, path] : files) {
|
||||
if (m_Total + data.size() <= DISK_CACHE_BYTES * 3 / 4) break;
|
||||
const auto size = std::filesystem::file_size(path, ec);
|
||||
if (std::filesystem::remove(path, ec)) m_Total -= std::min(m_Total, size);
|
||||
}
|
||||
}
|
||||
// Written next to the target and renamed, so a reader never sees half a file
|
||||
std::ostringstream temporary;
|
||||
temporary << target.string() << "." << std::this_thread::get_id() << ".tmp";
|
||||
{
|
||||
std::ofstream file(temporary.str(), std::ios::binary | std::ios::trunc);
|
||||
if (!file.write(data.data(), static_cast<std::streamsize>(data.size()))) return;
|
||||
}
|
||||
const auto existed = std::filesystem::exists(target, ec);
|
||||
std::filesystem::rename(temporary.str(), target, ec);
|
||||
if (!ec && !existed) m_Total += data.size();
|
||||
}
|
||||
if (*total + data.size() > DISK_CACHE_BYTES) {
|
||||
std::vector<std::pair<std::filesystem::file_time_type, std::filesystem::path>> files;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(CACHE_DIR, ec)) files.emplace_back(entry.last_write_time(ec), entry.path());
|
||||
std::sort(files.begin(), files.end());
|
||||
for (const auto& [time, path] : files) {
|
||||
if (*total + data.size() <= DISK_CACHE_BYTES * 3 / 4) break;
|
||||
const auto size = std::filesystem::file_size(path, ec);
|
||||
if (std::filesystem::remove(path, ec)) *total -= std::min(*total, size);
|
||||
|
||||
// Bytes in the cache
|
||||
uintmax_t Total() {
|
||||
std::lock_guard lock(m_Mutex);
|
||||
CountLocked();
|
||||
return m_Total;
|
||||
}
|
||||
|
||||
private:
|
||||
void CountLocked() {
|
||||
if (m_Counted) return;
|
||||
m_Counted = true;
|
||||
std::error_code ec;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(CACHE_DIR, ec)) {
|
||||
if (entry.path().extension() == ".tmp") std::filesystem::remove(entry.path(), ec); // left by a crash
|
||||
else m_Total += entry.is_regular_file(ec) ? entry.file_size(ec) : 0;
|
||||
}
|
||||
}
|
||||
const auto temporary = target.string() + ".tmp";
|
||||
{
|
||||
std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (!file.write(data.data(), static_cast<std::streamsize>(data.size()))) return;
|
||||
|
||||
std::mutex m_Mutex;
|
||||
uintmax_t m_Total{};
|
||||
bool m_Counted{};
|
||||
};
|
||||
|
||||
DiskCache g_Disk;
|
||||
|
||||
using Bytes = std::shared_ptr<const std::string>;
|
||||
|
||||
// A TtlCache any thread may use
|
||||
class SharedCache {
|
||||
public:
|
||||
SharedCache(std::chrono::seconds ttl, size_t maxBytes) : m_Cache(ttl, maxBytes) {}
|
||||
Bytes Get(const std::string& key) {
|
||||
std::lock_guard lock(m_Mutex);
|
||||
const auto cached = m_Cache.Get(key);
|
||||
return cached ? *cached : nullptr;
|
||||
}
|
||||
std::filesystem::rename(temporary, target, ec);
|
||||
if (!ec) *total += data.size();
|
||||
void Put(const std::string& key, Bytes value) {
|
||||
if (!value) return;
|
||||
std::lock_guard lock(m_Mutex);
|
||||
const auto weight = value->size() + 256;
|
||||
m_Cache.Put(key, std::move(value), weight);
|
||||
}
|
||||
private:
|
||||
std::mutex m_Mutex;
|
||||
TtlCache<std::string, Bytes> m_Cache;
|
||||
};
|
||||
|
||||
SharedCache g_Models(std::chrono::hours(1), MESH_CACHE_BYTES); // "path|lod" -> NifFile::Encode's output
|
||||
SharedCache g_Embedded(std::chrono::hours(1), MESH_CACHE_BYTES); // "path#block" -> DDS of a texture stored in a .nif
|
||||
|
||||
// Conversions under way ("path|lod"), so a model asked for twice at once is converted once and both get it
|
||||
std::mutex g_ConvertingMutex;
|
||||
std::map<std::string, std::shared_future<Bytes>> g_Converting;
|
||||
|
||||
std::string ModelKey(const std::string& path, uint32_t lod) { return path + "|" + std::to_string(lod); }
|
||||
|
||||
// Where model `path` at `lod` is kept on disk, named after the source file's size and time so a changed client
|
||||
// file is converted again
|
||||
std::filesystem::path DiskPath(const std::string& key, const std::filesystem::path& file) {
|
||||
std::error_code ec;
|
||||
const auto size = std::filesystem::file_size(file, ec);
|
||||
const auto time = std::filesystem::last_write_time(file, ec).time_since_epoch().count();
|
||||
const auto diskKey = key + "|" + std::to_string(size) + "|" + std::to_string(time) + "|" + std::to_string(FORMAT_VERSION);
|
||||
return CACHE_DIR / (std::to_string(Fnv1a(diskKey)) + ".bin");
|
||||
}
|
||||
|
||||
Bytes Convert(const std::string& path, uint32_t lod, const std::filesystem::path& file, const std::filesystem::path& target) {
|
||||
if (auto encoded = ReadWhole(target)) return std::make_shared<const std::string>(std::move(*encoded));
|
||||
const auto data = ReadWhole(file);
|
||||
if (!data) return nullptr;
|
||||
std::string error;
|
||||
const auto model = NifFile::Parse(*data, lod, error);
|
||||
if (!model) {
|
||||
LOG_DEBUG("Could not read %s: %s", path.c_str(), error.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
const auto folder = FolderOf(path);
|
||||
std::vector<std::string> textures; // per mesh: a res path, "#<block>" for one stored in the .nif, or empty
|
||||
for (const auto& mesh : model->meshes) {
|
||||
if (mesh.material.embeddedTexture >= 0) textures.push_back("#" + std::to_string(mesh.material.embeddedTexture));
|
||||
else textures.push_back(mesh.material.texture.empty() ? std::string{} : FindTexture(folder, mesh.material.texture));
|
||||
}
|
||||
auto encoded = std::make_shared<const std::string>(NifFile::Encode(*model, textures));
|
||||
g_Disk.Store(target, *encoded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model `path` at `lod` in NifFile::Encode's format. Converting a big .nif takes a moment, so results are kept in
|
||||
* memory (MESH_CACHE_BYTES) and on disk (DISK_CACHE_BYTES), keyed by the file's size and time so a changed client
|
||||
* file is converted again.
|
||||
* Model `path` (on disk at `file`) at `lod` in NifFile::Encode's format. Converting a big .nif takes a moment, so
|
||||
* results are kept in memory (MESH_CACHE_BYTES; unless `keep` is false, for conversions ahead of time) and on disk
|
||||
* (DISK_CACHE_BYTES). Any thread; the same model asked for again while it converts waits for that conversion.
|
||||
*/
|
||||
std::shared_ptr<const std::string> Encoded(const std::string& path, uint32_t lod) {
|
||||
static TtlCache<std::string, std::shared_ptr<const std::string>> memory(std::chrono::hours(1), MESH_CACHE_BYTES);
|
||||
const auto key = path + "|" + std::to_string(lod);
|
||||
if (auto cached = memory.Get(key)) return *cached;
|
||||
Bytes Encoded(const std::string& path, uint32_t lod, const std::filesystem::path& file, bool keep = true) {
|
||||
const auto key = ModelKey(path, lod);
|
||||
if (auto cached = g_Models.Get(key)) return cached;
|
||||
|
||||
const auto file = ClientAssets::ResolveResFile(path);
|
||||
if (!file) return nullptr;
|
||||
std::error_code ec;
|
||||
const auto size = std::filesystem::file_size(*file, ec);
|
||||
const auto time = std::filesystem::last_write_time(*file, ec).time_since_epoch().count();
|
||||
const auto diskKey = key + "|" + std::to_string(size) + "|" + std::to_string(time) + "|" + std::to_string(FORMAT_VERSION);
|
||||
const auto target = CACHE_DIR / (std::to_string(Fnv1a(diskKey)) + ".bin");
|
||||
|
||||
auto encoded = ReadWhole(target);
|
||||
if (!encoded) {
|
||||
const auto data = ReadWhole(*file);
|
||||
if (!data) return nullptr;
|
||||
std::string error;
|
||||
const auto model = NifFile::Parse(*data, lod, error);
|
||||
if (!model) {
|
||||
LOG_DEBUG("Could not read %s: %s", path.c_str(), error.c_str());
|
||||
return nullptr;
|
||||
std::promise<Bytes> promise;
|
||||
std::shared_future<Bytes> converting;
|
||||
bool mine = false;
|
||||
{
|
||||
std::lock_guard lock(g_ConvertingMutex);
|
||||
const auto it = g_Converting.find(key);
|
||||
if (it != g_Converting.end()) {
|
||||
converting = it->second;
|
||||
} else {
|
||||
converting = promise.get_future().share();
|
||||
g_Converting.emplace(key, converting);
|
||||
mine = true;
|
||||
}
|
||||
const auto folder = FolderOf(path);
|
||||
std::vector<std::string> textures; // per mesh: a res path, "#<block>" for one stored in the .nif, or empty
|
||||
for (const auto& mesh : model->meshes) {
|
||||
if (mesh.material.embeddedTexture >= 0) textures.push_back("#" + std::to_string(mesh.material.embeddedTexture));
|
||||
else textures.push_back(mesh.material.texture.empty() ? std::string{} : FindTexture(folder, mesh.material.texture));
|
||||
}
|
||||
encoded = NifFile::Encode(*model, textures);
|
||||
StoreOnDisk(target, *encoded);
|
||||
}
|
||||
auto shared = std::make_shared<const std::string>(std::move(*encoded));
|
||||
memory.Put(key, shared, shared->size() + 256);
|
||||
return shared;
|
||||
if (!mine) {
|
||||
auto result = converting.get();
|
||||
if (keep) g_Models.Put(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Bytes result;
|
||||
try {
|
||||
result = Convert(path, lod, file, DiskPath(key, file));
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not convert %s: %s", path.c_str(), ex.what());
|
||||
}
|
||||
if (keep) g_Models.Put(key, result);
|
||||
{
|
||||
std::lock_guard lock(g_ConvertingMutex);
|
||||
g_Converting.erase(key);
|
||||
}
|
||||
promise.set_value(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// The "textures" list of an encoded model's header
|
||||
@@ -409,12 +515,145 @@ namespace {
|
||||
uint32_t LodOf(const HTTPContext& context) {
|
||||
return std::min(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "lod")).value_or(0), MAX_LOD);
|
||||
}
|
||||
|
||||
// ---- Converting on worker threads, so a big model never holds up the web server's one thread ----
|
||||
|
||||
WorkerPool g_Pool;
|
||||
|
||||
constexpr uintmax_t SMALL_MODEL_BYTES = 256 * 1024; // .nif files this small convert in the pool's fast lane
|
||||
constexpr uintmax_t LARGE_MODEL_BYTES = 4 * 1024 * 1024; // and this big wait behind everything smaller
|
||||
constexpr auto WARM_IDLE = std::chrono::seconds(90); // converting a zone ahead stops once nobody has asked for it this long
|
||||
constexpr uintmax_t WARM_DISK_BYTES = DISK_CACHE_BYTES * 3 / 4; // and when the disk cache is this full (it never evicts for it)
|
||||
constexpr size_t WARM_MAX_MODELS = 4000;
|
||||
|
||||
// When someone last asked for something of a zone, and the LOD they last asked a model at
|
||||
struct Activity {
|
||||
std::chrono::steady_clock::time_point last;
|
||||
uint32_t lod{ 1 }; // the world view's default detail
|
||||
};
|
||||
std::mutex g_ActivityMutex;
|
||||
std::map<uint32_t, Activity> g_Activity;
|
||||
|
||||
void Touch(uint32_t zoneId, std::optional<uint32_t> lod = std::nullopt) {
|
||||
std::lock_guard lock(g_ActivityMutex);
|
||||
auto& activity = g_Activity[zoneId];
|
||||
activity.last = std::chrono::steady_clock::now();
|
||||
if (lod) activity.lod = *lod;
|
||||
}
|
||||
|
||||
// The zone's activity while someone views it, nullopt once nobody has for WARM_IDLE
|
||||
std::optional<Activity> Viewed(uint32_t zoneId) {
|
||||
std::lock_guard lock(g_ActivityMutex);
|
||||
const auto it = g_Activity.find(zoneId);
|
||||
if (it == g_Activity.end() || std::chrono::steady_clock::now() - it->second.last > WARM_IDLE) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
// Flairs (small, and drawn around the camera) and small models first; big ones behind the rest
|
||||
WorkerPool::ePriority PriorityOf(const ZoneScenery& zone, const std::string& path, const std::filesystem::path& file) {
|
||||
if (zone.flairModels.contains(path)) return WorkerPool::ePriority::URGENT;
|
||||
std::error_code ec;
|
||||
const auto size = std::filesystem::file_size(file, ec);
|
||||
if (ec || size <= SMALL_MODEL_BYTES) return WorkerPool::ePriority::URGENT;
|
||||
return size >= LARGE_MODEL_BYTES ? WorkerPool::ePriority::LARGE : WorkerPool::ePriority::NORMAL;
|
||||
}
|
||||
|
||||
uint64_t WarmGroup(uint32_t zoneId) { return static_cast<uint64_t>(zoneId) + 1; }
|
||||
|
||||
/**
|
||||
* Convert models of a zone ahead of time (onto the disk cache), at the LOD its viewer last asked for, while
|
||||
* someone views it. The lowest priority: only when nothing else waits. Smallest first; `front` puts these before
|
||||
* the zone's other queued ones (the flairs). Web thread.
|
||||
*/
|
||||
void WarmUp(uint32_t zoneId, const std::vector<std::string>& paths, bool front) {
|
||||
if (!g_Pool.Running() || paths.empty()) return;
|
||||
Files(); // built here: workers only read it
|
||||
struct Item {
|
||||
std::string path;
|
||||
std::filesystem::path file;
|
||||
uintmax_t size{};
|
||||
};
|
||||
std::vector<Item> items;
|
||||
std::set<std::string> seen;
|
||||
std::error_code ec;
|
||||
for (const auto& path : paths) {
|
||||
if (!seen.insert(path).second) continue;
|
||||
const auto file = ClientAssets::ResolveResFile(path);
|
||||
if (file) items.push_back({ path, *file, std::filesystem::file_size(*file, ec) });
|
||||
}
|
||||
std::stable_sort(items.begin(), items.end(), [](const Item& a, const Item& b) { return a.size < b.size; });
|
||||
if (items.size() > WARM_MAX_MODELS) items.resize(WARM_MAX_MODELS);
|
||||
const auto group = WarmGroup(zoneId);
|
||||
// Queued at the front in reverse, so they still run smallest first
|
||||
if (front) std::reverse(items.begin(), items.end());
|
||||
for (auto& item : items) {
|
||||
g_Pool.Submit(WorkerPool::ePriority::BACKGROUND, [zoneId, group, path = std::move(item.path), file = std::move(item.file)] {
|
||||
const auto activity = Viewed(zoneId);
|
||||
if (!activity || g_Disk.Total() >= WARM_DISK_BYTES) {
|
||||
g_Pool.Cancel(group);
|
||||
return;
|
||||
}
|
||||
const auto key = ModelKey(path, activity->lod);
|
||||
std::error_code ec;
|
||||
if (g_Models.Get(key) || std::filesystem::exists(DiskPath(key, file), ec)) return;
|
||||
Encoded(path, activity->lod, file, false);
|
||||
}, group, front);
|
||||
}
|
||||
}
|
||||
|
||||
// Warm the zone's models when its manifest is asked for (again after nobody viewed it for a while)
|
||||
void WarmScenery(uint32_t zoneId, ZoneScenery& zone, bool flairs) {
|
||||
if (!Viewed(zoneId)) zone.warmedScenery = zone.warmedFlairs = false;
|
||||
Touch(zoneId);
|
||||
auto& warmed = flairs ? zone.warmedFlairs : zone.warmedScenery;
|
||||
if (warmed) return;
|
||||
warmed = true;
|
||||
if (flairs) WarmUp(zoneId, { zone.flairModels.begin(), zone.flairModels.end() }, true);
|
||||
else WarmUp(zoneId, zone.assets, false);
|
||||
}
|
||||
|
||||
// The texture `name` of model `path` (textures[slot] of its encoded form) as a DDS file. Any thread.
|
||||
std::optional<std::string> TextureBytes(const std::string& path, const std::filesystem::path& modelFile, const std::vector<std::string>& textures,
|
||||
const std::string& name, const std::filesystem::path& res) {
|
||||
if (!name.starts_with('#')) {
|
||||
const auto file = ClientAssets::ResolveResFile(name, res);
|
||||
return file ? ReadWhole(*file) : std::nullopt;
|
||||
}
|
||||
// Stored inside the model: reading a big .nif again for each of its textures would be slow, so they're kept
|
||||
if (const auto cached = g_Embedded.Get(path + name)) return *cached;
|
||||
const auto data = ReadWhole(modelFile);
|
||||
const auto block = GeneralUtils::TryParse<int32_t>(name.substr(1));
|
||||
if (!data || !block) return std::nullopt;
|
||||
// Every texture of the file at once, since the browser asks for them together
|
||||
for (const auto& other : textures) {
|
||||
if (other == name) continue;
|
||||
const auto otherBlock = other.starts_with('#') ? GeneralUtils::TryParse<int32_t>(other.substr(1)) : std::nullopt;
|
||||
auto file = otherBlock ? NifFile::EmbeddedTexture(*data, *otherBlock) : std::nullopt;
|
||||
if (file) g_Embedded.Put(path + other, std::make_shared<const std::string>(std::move(*file)));
|
||||
}
|
||||
auto dds = NifFile::EmbeddedTexture(*data, *block);
|
||||
if (dds) g_Embedded.Put(path + name, std::make_shared<const std::string>(*dds));
|
||||
return dds;
|
||||
}
|
||||
|
||||
// The model path of `asset` in the zone's manifests, or a 404 reply
|
||||
const std::string* AssetPath(HTTPReply& reply, uint32_t zoneId, uint32_t asset) {
|
||||
auto& zone = Zone(zoneId);
|
||||
// The flairs' models join the list when their manifest is first built (a browser may still have it cached)
|
||||
if (zone && asset >= zone->assets.size()) Flairs(zoneId, *zone);
|
||||
if (!zone || asset >= zone->assets.size()) {
|
||||
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model in this zone");
|
||||
return nullptr;
|
||||
}
|
||||
return &zone->assets[asset];
|
||||
}
|
||||
}
|
||||
|
||||
namespace Scenery {
|
||||
std::optional<std::string> ZoneJson(uint32_t zoneId) {
|
||||
const auto& zone = Zone(zoneId);
|
||||
auto& zone = Zone(zoneId);
|
||||
if (!zone) return std::nullopt;
|
||||
WarmScenery(zoneId, *zone, false);
|
||||
return zone->json;
|
||||
}
|
||||
|
||||
@@ -426,61 +665,85 @@ namespace Scenery {
|
||||
std::optional<std::string> FlairsJson(uint32_t zoneId) {
|
||||
auto& zone = Zone(zoneId);
|
||||
if (!zone) return std::nullopt;
|
||||
return Flairs(zoneId, *zone);
|
||||
const auto& flairs = Flairs(zoneId, *zone);
|
||||
WarmScenery(zoneId, *zone, true);
|
||||
return flairs;
|
||||
}
|
||||
|
||||
void ReplyMesh(HTTPReply& reply, uint32_t zoneId, uint32_t asset, uint32_t lod) {
|
||||
auto& zone = Zone(zoneId);
|
||||
// The flairs' models join the list when their manifest is first built (a browser may still have it cached)
|
||||
if (zone && asset >= zone->assets.size()) Flairs(zoneId, *zone);
|
||||
if (!zone || asset >= zone->assets.size()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model in this zone");
|
||||
const auto encoded = Encoded(zone->assets[asset], std::min(lod, MAX_LOD));
|
||||
if (!encoded) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Could not read this model");
|
||||
Binary(reply, *encoded);
|
||||
void ReplyMesh(HTTPReply& reply, const HTTPContext& context, uint32_t zoneId, uint32_t asset, uint32_t lod) {
|
||||
const auto* found = AssetPath(reply, zoneId, asset);
|
||||
if (!found) return;
|
||||
const auto path = *found;
|
||||
lod = std::min(lod, MAX_LOD);
|
||||
Touch(zoneId, lod);
|
||||
if (const auto cached = g_Models.Get(ModelKey(path, lod))) return Binary(reply, *cached);
|
||||
const auto file = ClientAssets::ResolveResFile(path);
|
||||
if (!file) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Could not read this model");
|
||||
Files(); // built here: workers only read it
|
||||
const auto deferred = Web::Defer(reply, context);
|
||||
g_Pool.Submit(PriorityOf(*Zone(zoneId), path, *file), [deferred, path, lod, file = *file] {
|
||||
if (deferred.Cancelled()) return;
|
||||
HTTPReply out;
|
||||
const auto encoded = Encoded(path, lod, file);
|
||||
if (encoded) Binary(out, *encoded);
|
||||
else JsonError(out, eHTTPStatusCode::NOT_FOUND, "Could not read this model");
|
||||
deferred.Send(std::move(out));
|
||||
});
|
||||
}
|
||||
|
||||
void ReplyTexture(HTTPReply& reply, uint32_t zoneId, uint32_t asset, uint32_t slot, uint32_t lod) {
|
||||
auto& zone = Zone(zoneId);
|
||||
if (zone && asset >= zone->assets.size()) Flairs(zoneId, *zone);
|
||||
if (!zone || asset >= zone->assets.size()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model in this zone");
|
||||
const auto encoded = Encoded(zone->assets[asset], std::min(lod, MAX_LOD));
|
||||
const auto textures = encoded ? TexturesOf(*encoded) : std::vector<std::string>{};
|
||||
if (slot >= textures.size() || textures[slot].empty()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such texture");
|
||||
const auto& texture = textures[slot];
|
||||
std::optional<std::string> dds;
|
||||
if (texture.starts_with('#')) {
|
||||
// Stored inside the model: reading a big .nif again for each of its textures would be slow, so they're kept
|
||||
static TtlCache<std::string, std::shared_ptr<const std::string>> embedded(std::chrono::hours(1), MESH_CACHE_BYTES);
|
||||
const auto key = zone->assets[asset] + texture;
|
||||
if (const auto cached = embedded.Get(key)) return Binary(reply, **cached);
|
||||
const auto data = ClientAssets::ReadResFile(zone->assets[asset]);
|
||||
const auto block = GeneralUtils::TryParse<int32_t>(texture.substr(1));
|
||||
if (data && block) {
|
||||
// Every texture of the file at once, since the browser asks for them together
|
||||
for (const auto& other : textures) {
|
||||
const auto otherBlock = other.starts_with('#') ? GeneralUtils::TryParse<int32_t>(other.substr(1)) : std::nullopt;
|
||||
auto file = otherBlock ? NifFile::EmbeddedTexture(*data, *otherBlock) : std::nullopt;
|
||||
if (!file) continue;
|
||||
const auto bytes = file->size();
|
||||
embedded.Put(zone->assets[asset] + other, std::make_shared<const std::string>(std::move(*file)), bytes);
|
||||
}
|
||||
dds = NifFile::EmbeddedTexture(*data, *block);
|
||||
}
|
||||
void ReplyTexture(HTTPReply& reply, const HTTPContext& context, uint32_t zoneId, uint32_t asset, uint32_t slot, uint32_t lod) {
|
||||
const auto* found = AssetPath(reply, zoneId, asset);
|
||||
if (!found) return;
|
||||
const auto path = *found;
|
||||
lod = std::min(lod, MAX_LOD);
|
||||
Touch(zoneId, lod);
|
||||
const auto file = ClientAssets::ResolveResFile(path);
|
||||
if (!file) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such texture");
|
||||
Files();
|
||||
// Quick when the model is converted already and the texture is a file of its own, or one kept from its model
|
||||
auto priority = WorkerPool::ePriority::URGENT;
|
||||
if (const auto cached = g_Models.Get(ModelKey(path, lod))) {
|
||||
const auto textures = TexturesOf(*cached);
|
||||
if (slot < textures.size() && textures[slot].starts_with('#') && !g_Embedded.Get(path + textures[slot])) priority = PriorityOf(*Zone(zoneId), path, *file);
|
||||
} else {
|
||||
dds = ClientAssets::ReadResFile(texture);
|
||||
priority = PriorityOf(*Zone(zoneId), path, *file);
|
||||
}
|
||||
if (!dds) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Could not read this texture");
|
||||
Binary(reply, std::move(*dds));
|
||||
const auto deferred = Web::Defer(reply, context);
|
||||
g_Pool.Submit(priority, [deferred, path, lod, slot, file = *file, res = ClientAssets::ResFolder()] {
|
||||
if (deferred.Cancelled()) return;
|
||||
HTTPReply out;
|
||||
const auto encoded = Encoded(path, lod, file);
|
||||
const auto textures = encoded ? TexturesOf(*encoded) : std::vector<std::string>{};
|
||||
if (slot >= textures.size() || textures[slot].empty()) {
|
||||
JsonError(out, eHTTPStatusCode::NOT_FOUND, "No such texture");
|
||||
} else if (auto dds = TextureBytes(path, file, textures, textures[slot], res)) {
|
||||
Binary(out, std::move(*dds));
|
||||
} else {
|
||||
JsonError(out, eHTTPStatusCode::NOT_FOUND, "Could not read this texture");
|
||||
}
|
||||
deferred.Send(std::move(out));
|
||||
});
|
||||
}
|
||||
|
||||
void Shutdown() {
|
||||
g_Pool.Stop();
|
||||
}
|
||||
|
||||
void RegisterRoutes() {
|
||||
auto threads = Game::config ? Game::config->GetValue<uint32_t>("scenery_workers", 0) : 0;
|
||||
if (threads == 0) threads = static_cast<uint32_t>(WorkerPool::DefaultThreads(std::thread::hardware_concurrency()));
|
||||
threads = std::clamp<uint32_t>(threads, 2, 16);
|
||||
// Converting ahead of time never takes more than half of the threads besides the fast lane
|
||||
g_Pool.Start(threads, std::max<size_t>(1, (threads - 1) / 2));
|
||||
LOG("Converting scenery models with %u threads", threads);
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/scenery/:zone/mesh/:asset", 0,
|
||||
"Model `asset` of a zone's scenery (see the scenery routes of properties and /world3d), converted from the client's .nif. Query: ?lod=0 (most detailed) to 3",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto zone = PathId<uint32_t>(context.path, 2);
|
||||
const auto asset = PathId<uint32_t>(context.path, 4);
|
||||
if (!zone || !asset) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid zone or model");
|
||||
ReplyMesh(reply, *zone, *asset, LodOf(context));
|
||||
ReplyMesh(reply, context, *zone, *asset, LodOf(context));
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/scenery/:zone/texture/:asset/:slot", 0,
|
||||
@@ -490,7 +753,7 @@ namespace Scenery {
|
||||
const auto asset = PathId<uint32_t>(context.path, 4);
|
||||
const auto slot = PathId<uint32_t>(context.path, 5);
|
||||
if (!zone || !asset || !slot) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid zone, model or texture");
|
||||
ReplyTexture(reply, *zone, *asset, *slot, LodOf(context));
|
||||
ReplyTexture(reply, context, *zone, *asset, *slot, LodOf(context));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <string>
|
||||
|
||||
struct HTTPReply;
|
||||
struct HTTPContext;
|
||||
|
||||
namespace WorldScene {
|
||||
struct Object;
|
||||
@@ -19,6 +20,12 @@ namespace WorldScene {
|
||||
* wants, nearest first. Models are converted from .nif on the server (NifFile) to a small binary format and kept in a
|
||||
* bounded in-memory cache; textures are sent as the client's own DDS files (textures stored inside a .nif are
|
||||
* wrapped as DDS), which the browser decodes itself, so nothing needs ImageMagick.
|
||||
*
|
||||
* Converting a big model takes a while, so models and textures not in memory are answered from a few worker threads
|
||||
* (Web::Defer, WorkerPool; scenery_workers in dashboardconfig.ini) and the web server keeps answering meanwhile.
|
||||
* Flairs and small models go first, in a lane of their own; the same model asked for twice at once is converted
|
||||
* once. When a zone's manifest is asked for, its models are converted ahead of time onto the disk cache while
|
||||
* someone views the zone.
|
||||
*/
|
||||
namespace Scenery {
|
||||
/**
|
||||
@@ -40,12 +47,17 @@ namespace Scenery {
|
||||
// Whether the client draws a model for this scene object (its render component's, or its nif_name)
|
||||
bool HasModel(const WorldScene::Object& object);
|
||||
|
||||
// Reply with model `asset` of the zone's manifest (NifFile::Encode), `lod` 0 the most detailed
|
||||
void ReplyMesh(HTTPReply& reply, uint32_t zoneId, uint32_t asset, uint32_t lod);
|
||||
// Reply with model `asset` of the zone's manifest (NifFile::Encode), `lod` 0 the most detailed. Deferred (answered
|
||||
// from a worker thread) unless the model is in memory.
|
||||
void ReplyMesh(HTTPReply& reply, const HTTPContext& context, uint32_t zoneId, uint32_t asset, uint32_t lod);
|
||||
|
||||
// Reply with texture `slot` (the model's "textures" list at that `lod`) of model `asset`, as a DDS file
|
||||
void ReplyTexture(HTTPReply& reply, uint32_t zoneId, uint32_t asset, uint32_t slot, uint32_t lod);
|
||||
// Reply with texture `slot` (the model's "textures" list at that `lod`) of model `asset`, as a DDS file. Deferred.
|
||||
void ReplyTexture(HTTPReply& reply, const HTTPContext& context, uint32_t zoneId, uint32_t asset, uint32_t slot, uint32_t lod);
|
||||
|
||||
// /api/scenery/:zone/mesh/:asset and /api/scenery/:zone/texture/:asset/:slot, for anyone signed in
|
||||
// /api/scenery/:zone/mesh/:asset and /api/scenery/:zone/texture/:asset/:slot, for anyone signed in; starts the
|
||||
// conversion threads
|
||||
void RegisterRoutes();
|
||||
|
||||
// Stop the conversion threads (queued work is dropped)
|
||||
void Shutdown();
|
||||
}
|
||||
|
||||
@@ -278,6 +278,9 @@ namespace {
|
||||
c.Add(Bool(DASHBOARD, "secure_cookies", "HTTPS only cookies", "Turn on when the dashboard is served over HTTPS.", false));
|
||||
c.Add(Bool(DASHBOARD, "behind_proxy", "Behind a reverse proxy", "Use the proxy's X-Forwarded-For for rate limits. Only when the dashboard can't be reached directly.", false));
|
||||
c.Add(Unit(Int(DASHBOARD, "broadcast_interval", "Live update interval", "How often server status is pushed to open pages.", "2000", 250, 60000, true), "ms"));
|
||||
c.Add(Unit(Int(DASHBOARD, "scenery_workers", "3D model conversion threads",
|
||||
"Threads converting the client's models for the 3D views, so the dashboard keeps answering meanwhile. One of them only takes flairs and small models. 0 picks half the CPU cores (2 to 4).",
|
||||
"0", 0, 16, true), "threads"));
|
||||
|
||||
c.AddSection("Keys", "Only in the .ini file or the environment.");
|
||||
c.Add(Secret(DASHBOARD, "jwt_secret", "Session signing secret", "At least 32 characters; empty makes one. Changing it signs everyone out.", true));
|
||||
|
||||
@@ -342,7 +342,7 @@ void RegisterShowcaseRoutes() {
|
||||
if (!zone) return;
|
||||
const auto asset = PathId<uint32_t>(context.path, 5);
|
||||
if (!asset) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid model");
|
||||
Scenery::ReplyMesh(reply, *zone, *asset, lodOf(context));
|
||||
Scenery::ReplyMesh(reply, context, *zone, *asset, lodOf(context));
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/showcase/scenery/:zone/texture/:asset/:slot", PUBLIC, "A scenery texture of a property zone as DDS for the showcase's 3D view",
|
||||
@@ -352,6 +352,6 @@ void RegisterShowcaseRoutes() {
|
||||
const auto asset = PathId<uint32_t>(context.path, 5);
|
||||
const auto slot = PathId<uint32_t>(context.path, 6);
|
||||
if (!asset || !slot) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid model or texture");
|
||||
Scenery::ReplyTexture(reply, *zone, *asset, *slot, lodOf(context));
|
||||
Scenery::ReplyTexture(reply, context, *zone, *asset, *slot, lodOf(context));
|
||||
});
|
||||
}
|
||||
|
||||
140
dDashboardServer/routes/WorkerPool.cpp
Normal file
140
dDashboardServer/routes/WorkerPool.cpp
Normal file
@@ -0,0 +1,140 @@
|
||||
#include "WorkerPool.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
|
||||
std::optional<WorkerPool::ePriority> WorkerPool::Pick(const std::array<size_t, PRIORITIES>& queued, bool fastLane, size_t runningBackground, size_t maxBackground) {
|
||||
for (size_t i = 0; i < PRIORITIES; i++) {
|
||||
const auto priority = static_cast<ePriority>(i);
|
||||
if (fastLane && priority != ePriority::URGENT) break;
|
||||
if (priority == ePriority::BACKGROUND && runningBackground >= maxBackground) break;
|
||||
if (queued[i] > 0) return priority;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
size_t WorkerPool::DefaultThreads(size_t cores) {
|
||||
return std::clamp<size_t>(cores / 2, 2, 4);
|
||||
}
|
||||
|
||||
void WorkerPool::Start(size_t threads, size_t maxBackground) {
|
||||
Stop();
|
||||
std::lock_guard lock(m_Mutex);
|
||||
m_Stopping = false;
|
||||
m_MaxBackground = std::max<size_t>(1, maxBackground);
|
||||
threads = std::max<size_t>(2, threads);
|
||||
for (size_t i = 0; i < threads; i++) m_Threads.emplace_back([this, i] { Work(i == 0); });
|
||||
}
|
||||
|
||||
void WorkerPool::Stop() {
|
||||
std::vector<std::thread> threads;
|
||||
{
|
||||
std::lock_guard lock(m_Mutex);
|
||||
m_Stopping = true;
|
||||
for (auto& queue : m_Queues) queue.clear();
|
||||
threads.swap(m_Threads);
|
||||
}
|
||||
m_Wake.notify_all();
|
||||
for (auto& thread : threads) thread.join();
|
||||
m_Idle.notify_all();
|
||||
}
|
||||
|
||||
void WorkerPool::Submit(ePriority priority, Job job, uint64_t group, bool front) {
|
||||
if (!job) return;
|
||||
{
|
||||
std::lock_guard lock(m_Mutex);
|
||||
if (!m_Threads.empty()) {
|
||||
auto& queue = m_Queues[static_cast<size_t>(priority)];
|
||||
Entry entry{ std::move(job), group };
|
||||
if (front) queue.push_front(std::move(entry));
|
||||
else queue.push_back(std::move(entry));
|
||||
job = nullptr;
|
||||
}
|
||||
}
|
||||
if (!job) {
|
||||
// Wake them all: the one woken might be the fast lane, which can't take this
|
||||
m_Wake.notify_all();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
job();
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("A background job failed: %s", ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
size_t WorkerPool::Cancel(uint64_t group) {
|
||||
if (group == 0) return 0;
|
||||
size_t dropped = 0;
|
||||
{
|
||||
std::lock_guard lock(m_Mutex);
|
||||
for (auto& queue : m_Queues) {
|
||||
const auto before = queue.size();
|
||||
std::erase_if(queue, [group](const Entry& entry) { return entry.group == group; });
|
||||
dropped += before - queue.size();
|
||||
}
|
||||
}
|
||||
m_Idle.notify_all();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
size_t WorkerPool::Queued() const {
|
||||
std::lock_guard lock(m_Mutex);
|
||||
size_t total = 0;
|
||||
for (const auto& queue : m_Queues) total += queue.size();
|
||||
return total;
|
||||
}
|
||||
|
||||
size_t WorkerPool::Active() const {
|
||||
std::lock_guard lock(m_Mutex);
|
||||
return m_Active;
|
||||
}
|
||||
|
||||
void WorkerPool::WaitIdle() {
|
||||
std::unique_lock lock(m_Mutex);
|
||||
m_Idle.wait(lock, [this] {
|
||||
if (m_Active > 0) return false;
|
||||
return std::all_of(m_Queues.begin(), m_Queues.end(), [](const auto& queue) { return queue.empty(); });
|
||||
});
|
||||
}
|
||||
|
||||
void WorkerPool::Work(bool fastLane) {
|
||||
std::unique_lock lock(m_Mutex);
|
||||
while (true) {
|
||||
std::optional<ePriority> priority;
|
||||
m_Wake.wait(lock, [&] {
|
||||
if (m_Stopping) return true;
|
||||
std::array<size_t, PRIORITIES> queued{};
|
||||
for (size_t i = 0; i < PRIORITIES; i++) queued[i] = m_Queues[i].size();
|
||||
priority = Pick(queued, fastLane, m_RunningBackground, m_MaxBackground);
|
||||
return priority.has_value();
|
||||
});
|
||||
if (m_Stopping) return;
|
||||
|
||||
auto& queue = m_Queues[static_cast<size_t>(*priority)];
|
||||
auto entry = std::move(queue.front());
|
||||
queue.pop_front();
|
||||
const bool background = *priority == ePriority::BACKGROUND;
|
||||
if (background) m_RunningBackground++;
|
||||
m_Active++;
|
||||
lock.unlock();
|
||||
|
||||
try {
|
||||
entry.job();
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("A background job failed: %s", ex.what());
|
||||
}
|
||||
entry.job = nullptr; // what it holds is released outside the lock
|
||||
|
||||
lock.lock();
|
||||
m_Active--;
|
||||
if (background) {
|
||||
m_RunningBackground--;
|
||||
m_Wake.notify_all(); // another background job may start now
|
||||
}
|
||||
m_Idle.notify_all();
|
||||
}
|
||||
}
|
||||
95
dDashboardServer/routes/WorkerPool.h
Normal file
95
dDashboardServer/routes/WorkerPool.h
Normal file
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* A few worker threads for slow work that routes hand off with Web::Defer (converting the client's models), so the
|
||||
* web server's one thread keeps answering. Jobs run by priority, in the order they came within one.
|
||||
*
|
||||
* The first thread is a fast lane that only runs URGENT jobs, so something small and wanted now (a flair near the
|
||||
* camera) never waits behind big conversions filling the other threads. BACKGROUND jobs (converting ahead of time)
|
||||
* only run when nothing else waits, and only a few at once, so they never take every thread.
|
||||
*/
|
||||
class WorkerPool {
|
||||
public:
|
||||
enum class ePriority : uint8_t {
|
||||
URGENT, // small and wanted now: the fast lane takes these too
|
||||
NORMAL,
|
||||
LARGE, // big jobs someone waits for
|
||||
BACKGROUND, // nobody waits for it
|
||||
};
|
||||
static constexpr size_t PRIORITIES = 4;
|
||||
|
||||
using Job = std::function<void()>;
|
||||
|
||||
WorkerPool() = default;
|
||||
~WorkerPool() { Stop(); }
|
||||
WorkerPool(const WorkerPool&) = delete;
|
||||
WorkerPool& operator=(const WorkerPool&) = delete;
|
||||
|
||||
/**
|
||||
* Start `threads` workers (at least 2: the fast lane and one more). At most `maxBackground` BACKGROUND jobs run
|
||||
* at once (at least 1).
|
||||
*/
|
||||
void Start(size_t threads, size_t maxBackground = 1);
|
||||
|
||||
// Drop the queued jobs and wait for the running ones
|
||||
void Stop();
|
||||
|
||||
bool Running() const { return !m_Threads.empty(); }
|
||||
size_t Threads() const { return m_Threads.size(); }
|
||||
|
||||
/**
|
||||
* Queue a job. `group` (0 for none) lets Cancel drop jobs queued together; `front` puts it before the others of
|
||||
* its priority. Without threads (not started) the job runs right away on the caller's thread.
|
||||
*/
|
||||
void Submit(ePriority priority, Job job, uint64_t group = 0, bool front = false);
|
||||
|
||||
// Drop the queued jobs of `group`; returns how many
|
||||
size_t Cancel(uint64_t group);
|
||||
|
||||
size_t Queued() const;
|
||||
size_t Active() const;
|
||||
|
||||
// Wait until nothing is queued or running (for tests)
|
||||
void WaitIdle();
|
||||
|
||||
/**
|
||||
* Which priority a worker takes next from queues of these lengths: the most urgent one waiting, only URGENT for
|
||||
* the fast lane, and BACKGROUND only while fewer than maxBackground of those run. nullopt: nothing for it.
|
||||
*/
|
||||
static std::optional<ePriority> Pick(const std::array<size_t, PRIORITIES>& queued, bool fastLane, size_t runningBackground, size_t maxBackground);
|
||||
|
||||
/**
|
||||
* The default number of threads for this many CPU cores: half of them, from 2 to 4 (a conversion is one core's
|
||||
* work, and the game servers on the same machine need the rest)
|
||||
*/
|
||||
static size_t DefaultThreads(size_t cores);
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
Job job;
|
||||
uint64_t group{};
|
||||
};
|
||||
|
||||
void Work(bool fastLane);
|
||||
|
||||
mutable std::mutex m_Mutex;
|
||||
std::condition_variable m_Wake;
|
||||
std::condition_variable m_Idle;
|
||||
std::array<std::deque<Entry>, PRIORITIES> m_Queues;
|
||||
std::vector<std::thread> m_Threads;
|
||||
size_t m_MaxBackground{ 1 };
|
||||
size_t m_RunningBackground{};
|
||||
size_t m_Active{};
|
||||
bool m_Stopping{};
|
||||
};
|
||||
Reference in New Issue
Block a user