#ifndef __WORLDFILEWATCH__H__ #define __WORLDFILEWATCH__H__ #include #include #include #include #include #include #include #include #include #include "FdbSnapshot.h" #include "ZoneFileLog.h" #include "master/InstanceMigration.h" /** * World hot reload without master's state (docs/WorldHotReload.md): which zone files the running instances loaded, * which of those changed on disk, and which instances to replace. Header only, so it is unit tested without a master. * * Master reads (hashes) each watched file itself: once when a world first reports it, and again whenever its size or * mtime changed and then held still for one poll (FdbSnapshot::Watcher). An instance is stale when a file it loaded * has another hash on disk now. Files a world read from the client's packs are listed but not watched. */ namespace WorldFileWatch { using Stamp = FdbSnapshot::Stamp; struct InstanceKey { uint32_t zone{}; uint32_t instance{}; auto operator<=>(const InstanceKey&) const = default; }; struct FileState { ZoneFileLog::eKind kind{}; bool packed{}; uint64_t reportedSize{}; // what the first world to report it read uint64_t reportedHash{}; std::optional hash; // master's read of the file on disk uint64_t size{}; bool missing{}; // the last poll or read found no file bool hashing{}; FdbSnapshot::Watcher watcher; }; struct Loaded { uint32_t clone{}; std::map files; // path -> hash the world read }; // A file to hash now, with the stamp it had when the poll saw it (accepted once the hash is in) struct HashJob { std::string path; Stamp stamp; }; class Tracker { public: // A world reported the files it loaded (replaces what that instance reported before) void Report(uint32_t zone, uint32_t instance, uint32_t clone, const std::vector& files) { auto& loaded = m_Instances[{ zone, instance }]; loaded = {}; loaded.clone = clone; for (const auto& file : files) { loaded.files[file.path] = file.hash; auto [it, added] = m_Files.try_emplace(file.path); if (!added) continue; it->second.kind = file.kind; it->second.packed = file.packed; it->second.reportedSize = file.size; it->second.reportedHash = file.hash; } } // An instance stopped; files no instance loaded any more stop being watched void Forget(uint32_t zone, uint32_t instance) { m_Instances.erase({ zone, instance }); std::erase_if(m_Files, [this](const auto& entry) { return !entry.second.hashing && !IsLoaded(entry.first); }); } /** * Looks at every watched file (stampOf: its size and mtime now). Returns the ones to hash: files master hasn't * read yet, and files whose size or mtime changed and held still since the last poll. Those are marked as * being hashed until Hashed is called for them. */ std::vector Poll(const std::function& stampOf) { std::vector due; for (auto& [path, file] : m_Files) { if (file.packed || file.hashing) continue; const auto stamp = stampOf(path); file.missing = !stamp.exists; const bool first = !file.hash && stamp.exists; if (!file.watcher.Poll(stamp) && !first) continue; file.hashing = true; due.push_back({ path, stamp }); } return due; } /** * A hash finished (hash nullopt: the file couldn't be read). Returns true when the file on disk is another * version than master read before. */ bool Hashed(const HashJob& job, std::optional hash, uint64_t size) { const auto it = m_Files.find(job.path); if (it == m_Files.end()) return false; auto& file = it->second; file.hashing = false; // Taken either way, so an unreadable file isn't read again every poll; the next change to it is file.watcher.Accept(job.stamp); if (!hash) { file.missing = true; if (!IsLoaded(job.path)) m_Files.erase(it); return false; } file.missing = false; const bool changed = file.hash && *file.hash != *hash; file.hash = hash; file.size = size; if (!IsLoaded(job.path)) m_Files.erase(it); return changed; } // A file this instance loaded is another version on disk now bool IsStale(const InstanceKey& key) const { const auto it = m_Instances.find(key); if (it == m_Instances.end()) return false; for (const auto& [path, loadedHash] : it->second.files) { const auto file = m_Files.find(path); if (file != m_Files.end() && file->second.hash && *file->second.hash != loadedHash) return true; } return false; } std::vector Stale() const { std::vector stale; for (const auto& [key, loaded] : m_Instances) { if (IsStale(key)) stale.push_back(key); } return stale; } // The versions on disk of the files this instance loaded; an automatic reload is tried once per signature uint64_t Signature(const InstanceKey& key) const { uint64_t signature = 14695981039346656037ULL; const auto it = m_Instances.find(key); if (it == m_Instances.end()) return signature; for (const auto& [path, loadedHash] : it->second.files) { const auto file = m_Files.find(path); const uint64_t onDisk = file != m_Files.end() && file->second.hash ? *file->second.hash : loadedHash; signature = FdbSnapshot::Hash(reinterpret_cast(path.data()), path.size(), signature); signature = FdbSnapshot::Hash(reinterpret_cast(&onDisk), sizeof(onDisk), signature); } return signature; } // The files of a stale instance that changed (for the log) std::vector ChangedFiles(const InstanceKey& key) const { std::vector changed; const auto it = m_Instances.find(key); if (it == m_Instances.end()) return changed; for (const auto& [path, loadedHash] : it->second.files) { const auto file = m_Files.find(path); if (file != m_Files.end() && file->second.hash && *file->second.hash != loadedHash) changed.push_back(path); } return changed; } // Any instance of zone loaded another version of path than is on disk bool IsChanged(uint32_t zone, const std::string& path) const { const auto file = m_Files.find(path); if (file == m_Files.end() || !file->second.hash) return false; for (const auto& [key, loaded] : m_Instances) { if (key.zone != zone) continue; const auto it = loaded.files.find(path); if (it != loaded.files.end() && it->second != *file->second.hash) return true; } return false; } // Every file some instance of zone loaded std::set FilesOf(uint32_t zone) const { std::set files; for (const auto& [key, loaded] : m_Instances) { if (key.zone != zone) continue; for (const auto& [path, hash] : loaded.files) files.insert(path); } return files; } std::set Zones() const { std::set zones; for (const auto& [key, loaded] : m_Instances) zones.insert(key.zone); return zones; } bool Knows(const InstanceKey& key) const { return m_Instances.contains(key); } const std::map& Files() const { return m_Files; } private: bool IsLoaded(const std::string& path) const { for (const auto& [key, loaded] : m_Instances) { if (loaded.files.contains(path)) return true; } return false; } std::map m_Files; std::map m_Instances; }; enum class eAction : uint8_t { REPLACE, // start a new instance (same clone, same password), move its players there, stop it START_THEN_STOP, // nobody there, but the zone always has an instance: start a new one, stop this one STOP, // nobody there: stop it (a new instance starts when someone goes there) SKIP, // A property with players: never moved (building in progress isn't saved). It takes nobody new, its players // are told an update is waiting, and it stops once everyone left (OutdatedInstances.h) KEEP_UNTIL_EMPTY, }; inline const char* ActionName(eAction action) { switch (action) { case eAction::REPLACE: return "replace"; case eAction::START_THEN_STOP: return "start a new one, then stop it"; case eAction::STOP: return "stop"; case eAction::SKIP: return "skip"; case eAction::KEEP_UNTIL_EMPTY: return "keep until everyone left"; } return "skip"; } struct Choice { InstanceMigration::InstanceView view; eAction action{ eAction::SKIP }; std::string reason; }; /** * What to do with each instance wanted() picks: replace it when players are there (a property is kept until they * all left instead), stop it when it is empty (a zone in keepZones gets one new public instance first). Character selection loads no zone; instances still starting * load the files on disk now (and report them); instances shutting down or being emptied already are left alone. */ inline std::vector Choose(const std::vector& instances, const std::function& wanted, const std::set& keepZones) { std::vector choices; for (const auto& view : instances) { if (!wanted(view)) continue; Choice choice; choice.view = view; if (view.zoneId == 0) choice.reason = "Character selection loads no zone"; else if (view.shuttingDown) choice.reason = "Already shutting down"; else if (view.draining) choice.reason = "Its players are already being moved"; else if (!view.ready) choice.reason = "Still starting; it loads the files on disk now"; else if (view.players > 0) choice.action = view.cloneId != 0 ? eAction::KEEP_UNTIL_EMPTY : eAction::REPLACE; else choice.action = eAction::STOP; choices.push_back(std::move(choice)); } // A zone that always has an instance keeps one: a public instance replaced for its players, or else one new // instance started for the first empty public one std::set keptZones; for (const auto& choice : choices) { if (choice.action == eAction::REPLACE && !choice.view.isPrivate && choice.view.cloneId == 0) keptZones.insert(choice.view.zoneId); } for (auto& choice : choices) { const auto& view = choice.view; if (choice.action != eAction::STOP || !keepZones.contains(view.zoneId) || view.isPrivate || view.cloneId != 0 || keptZones.contains(view.zoneId)) continue; choice.action = eAction::START_THEN_STOP; keptZones.insert(view.zoneId); } return choices; } } #endif //!__WORLDFILEWATCH__H__