| /* | |
| Licensed to the Apache Software Foundation (ASF) under one | |
| or more contributor license agreements. See the NOTICE file | |
| distributed with this work for additional information | |
| regarding copyright ownership. The ASF licenses this file | |
| to you 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. | |
| */ | |
| #include <algorithm> | |
| #include <array> | |
| #include <cstddef> | |
| #include <cstdio> | |
| #include <cstring> | |
| #include <filesystem> | |
| #include <unordered_set> | |
| #include "ts/ts.h" | |
| #include "cripts/CacheGroup.hpp" | |
| inline static uint32_t | |
| _make_prefix_int(cripts::string_view key) | |
| { | |
| uint32_t prefix = 0; | |
| std::memcpy(&prefix, key.data(), std::min<size_t>(4, key.size())); | |
| return prefix; | |
| } | |
| // Stuff around the disk sync continuation | |
| constexpr auto _CONT_SYNC_INTERVAL = 10; // How often to run the continuation | |
| constexpr int _SYNC_GROUP_EVERY = 60; // Sync each group every 60s | |
| int | |
| _cripts_cache_group_sync(TSCont cont, TSEvent /* event */, void * /* edata */) | |
| { | |
| auto *mgr = static_cast<cripts::Cache::Group::Manager *>(TSContDataGet(cont)); | |
| std::lock_guard lock(mgr->_mutex); | |
| auto &groups = mgr->_groups; | |
| constexpr size_t runs_per_window = _SYNC_GROUP_EVERY / _CONT_SYNC_INTERVAL; | |
| const size_t max_to_process = (groups.size() + (runs_per_window - 1)) / runs_per_window; | |
| size_t processed = 0; | |
| auto now = cripts::Time::Clock::now(); | |
| for (auto it = groups.begin(); it != groups.end() && processed < max_to_process;) { | |
| if (auto group = it->second.lock()) { | |
| if (group->LastSync() + std::chrono::seconds{_SYNC_GROUP_EVERY} < now) { | |
| group->WriteToDisk(); | |
| ++processed; | |
| } | |
| ++it; | |
| } else { | |
| // The group has been deleted, remove it from the map | |
| it = groups.erase(it); | |
| } | |
| } | |
| return TS_SUCCESS; | |
| } | |
| namespace cripts | |
| { | |
| void | |
| Cache::Group::Initialize(const std::string &name, const std::string &base_dir, size_t max_entries, size_t num_maps, | |
| std::chrono::seconds max_age) | |
| { | |
| cripts::Time::Point zero = cripts::Time::Point{}; | |
| cripts::Time::Point now = cripts::Time::Clock::now(); | |
| _name = name; | |
| _num_maps = num_maps; | |
| _max_entries = max_entries; | |
| _max_age = max_age; | |
| _base_dir = base_dir + "/" + _name; | |
| _log_path = _base_dir + "/" + "txn.log"; | |
| if (!std::filesystem::exists(_base_dir)) { | |
| std::error_code ec; | |
| std::filesystem::create_directories(_base_dir, ec); | |
| if (ec) { | |
| TSWarning("cripts::Cache::Group: Failed to create directory `%s': %s", _base_dir.c_str(), ec.message().c_str()); | |
| } else { | |
| std::filesystem::permissions(_base_dir, std::filesystem::perms::group_write, std::filesystem::perm_options::add, ec); | |
| if (ec) { | |
| TSWarning("cripts::Cache::Group: Failed to set permissions on `%s': %s", _base_dir.c_str(), ec.message().c_str()); | |
| } | |
| } | |
| } | |
| for (size_t ix = 0; ix < _num_maps; ++ix) { | |
| _slots.emplace_back(_MapSlot{.map = std::make_unique<_MapType>(), | |
| .path = _base_dir + "/map_" + std::to_string(ix) + ".bin", | |
| .created = zero, | |
| .last_write = zero, | |
| .last_sync = zero}); | |
| } | |
| _slots[0].created = now; | |
| _slots[0].last_write = now; | |
| LoadFromDisk(); | |
| _last_sync = now; | |
| } | |
| void | |
| Cache::Group::Insert(cripts::string_view key) | |
| { | |
| static const std::hash<cripts::string_view> hasher; | |
| // Trim any "'s and white spaces from the key | |
| key.trim_if(&isspace).trim('"'); | |
| auto now = cripts::Time::Clock::now(); | |
| auto hash = static_cast<uint64_t>(hasher(key)); | |
| uint32_t prefix = _make_prefix_int(key); | |
| std::unique_lock lock(_mutex); | |
| auto &slot = _slots[_map_index]; | |
| auto it = slot.map->find(hash); | |
| if (it != slot.map->end() && it->second.length == key.size() && it->second.prefix == prefix) { | |
| it->second.timestamp = now; | |
| slot.last_write = now; | |
| appendLog(it->second); | |
| return; | |
| } | |
| Cache::Group::_Entry entry{.timestamp = now, .length = key.size(), .prefix = prefix, .hash = hash}; | |
| slot.map->insert_or_assign(hash, entry); | |
| slot.last_write = now; | |
| appendLog(entry); | |
| if (slot.map->size() > _max_entries || (now - slot.created) > _max_age) { | |
| _map_index = (_map_index + 1) % _slots.size(); | |
| auto &next_slot = _slots[_map_index]; | |
| if (next_slot.last_write > next_slot.last_sync) { | |
| TSWarning("cripts::Cache::Group: Rotating unsynced map for `%s'! This may lead to data loss.", _name.c_str()); | |
| } | |
| next_slot.map->clear(); | |
| next_slot.created = now; | |
| next_slot.last_write = now; | |
| syncMap(_map_index); // Sync to disk will make sure it's empty on disk | |
| } | |
| } | |
| void | |
| Cache::Group::Insert(const std::vector<cripts::string_view> &keys) | |
| { | |
| for (auto &key : keys) { | |
| Insert(key); | |
| } | |
| } | |
| bool | |
| Cache::Group::Lookup(cripts::string_view key, cripts::Time::Point age) const | |
| { | |
| // Trim any "'s and whitespaces from the key | |
| key.trim_if(&isspace).trim('"'); | |
| uint64_t hash = static_cast<uint64_t>(std::hash<cripts::string_view>{}(key)); | |
| std::shared_lock lock(_mutex); | |
| for (size_t i = 0; i < _slots.size(); ++i) { | |
| size_t map_index = (_map_index + _slots.size() - i) % _slots.size(); | |
| const auto &slot = _slots[map_index]; | |
| if (slot.last_write < age) { | |
| continue; // Skip maps that haven't been written to since this time | |
| } | |
| const auto &map = *slot.map; | |
| auto it = map.find(hash); | |
| if (it != map.end()) { | |
| const Cache::Group::_Entry &entry = it->second; | |
| if (entry.timestamp < age || entry.length != key.size() || entry.prefix != _make_prefix_int(key)) { | |
| continue; | |
| } | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| bool | |
| Cache::Group::Lookup(const std::vector<cripts::string_view> &keys, cripts::Time::Point age) const | |
| { | |
| for (auto &key : keys) { | |
| if (Lookup(key, age)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| void | |
| Cache::Group::LoadFromDisk() | |
| { | |
| std::unique_lock lock(_mutex); | |
| std::ifstream log(_log_path, std::ios::binary); | |
| std::unordered_set<uint64_t> loaded_hashes; | |
| for (size_t slot_ix = 0; slot_ix < _slots.size(); ++slot_ix) { | |
| auto &slot = _slots[slot_ix]; | |
| uint64_t version_id = 0; | |
| _MapHeader header{}; | |
| std::ifstream file(slot.path, std::ios::binary); | |
| if (!file) { | |
| continue; | |
| } | |
| file.read(reinterpret_cast<char *>(&version_id), sizeof(version_id)); | |
| if (version_id != VERSION) { | |
| TSWarning("cripts::Cache::Group: Version mismatch for map file: %s, expected %llu, got %llu. Skipping this map.", | |
| slot.path.c_str(), static_cast<unsigned long long>(VERSION), static_cast<unsigned long long>(version_id)); | |
| continue; | |
| } | |
| file.read(reinterpret_cast<char *>(&header), sizeof(header)); | |
| if (!file) { | |
| TSWarning("cripts::Cache::Group: Failed to read header from map file: %s. Skipping this map.", slot.path.c_str()); | |
| continue; | |
| } | |
| slot.created = cripts::Time::Clock::from_time_t(header.created_ts); | |
| slot.last_write = cripts::Time::Clock::from_time_t(header.last_write_ts); | |
| slot.last_sync = cripts::Time::Clock::from_time_t(header.last_sync_ts); | |
| for (size_t i = 0; i < header.count; ++i) { | |
| Cache::Group::_Entry entry; | |
| file.read(reinterpret_cast<char *>(&entry), sizeof(entry)); | |
| if (!file) { | |
| TSWarning("cripts::Cache::Group: Failed to read entry %zu from map file: %s. Stopping entry load.", i, slot.path.c_str()); | |
| break; | |
| } | |
| if (!loaded_hashes.contains(entry.hash)) { | |
| slot.map->insert_or_assign(entry.hash, entry); | |
| loaded_hashes.insert(entry.hash); | |
| } | |
| } | |
| } | |
| // Sort the slots by creation time, newest first, since we'll start with index 0 upon loading | |
| std::ranges::sort(_slots, [](const _MapSlot &a, const _MapSlot &b) { return a.created > b.created; }); | |
| // Replay any entries from the transaction log, and then truncate it | |
| if (log) { | |
| Cache::Group::_Entry entry; | |
| auto last_write = cripts::Time::Clock::from_time_t(0); | |
| while (log.read(reinterpret_cast<char *>(&entry), sizeof(entry))) { | |
| _slots[0].map->insert_or_assign(entry.hash, entry); | |
| last_write = std::max(last_write, entry.timestamp); | |
| } | |
| _slots[0].last_write = last_write; | |
| clearLog(); | |
| } | |
| } | |
| void | |
| Cache::Group::WriteToDisk() | |
| { | |
| std::unique_lock lock(_mutex); | |
| auto now = cripts::Time::Clock::now(); | |
| bool any_dirty = false; | |
| bool all_synced = true; | |
| for (size_t ix = 0; ix < _slots.size(); ++ix) { | |
| if (_slots[ix].last_write > _slots[ix].last_sync) { | |
| auto old_sync = _slots[ix].last_sync; | |
| any_dirty = true; | |
| _slots[ix].last_sync = now; | |
| if (syncMap(ix)) { | |
| _last_sync = now; | |
| } else { | |
| _slots[ix].last_sync = old_sync; // revert so next call retries | |
| all_synced = false; | |
| } | |
| } | |
| } | |
| if (any_dirty && all_synced) { | |
| clearLog(); | |
| } | |
| } | |
| void | |
| Cache::Group::appendLog(const Cache::Group::_Entry &entry) | |
| { | |
| if (!_txn_log.is_open() || !_txn_log.good()) { | |
| _txn_log.open(_log_path, std::ios::app | std::ios::out | std::ios::binary); | |
| if (!_txn_log) { | |
| TSWarning("cripts::Cache::Group: Failed to open transaction log `%s'.", _log_path.c_str()); | |
| return; | |
| } | |
| } | |
| _txn_log.write(reinterpret_cast<const char *>(&entry), sizeof(entry)); | |
| _txn_log.flush(); | |
| } | |
| bool | |
| Cache::Group::syncMap(size_t index) | |
| { | |
| constexpr size_t BUFFER_SIZE = 64 * 1024; | |
| std::array<std::byte, BUFFER_SIZE> buffer; | |
| size_t buf_pos = 0; | |
| bool write_failed = false; | |
| const auto &slot = _slots[index]; | |
| const std::string tmp_path = slot.path + ".tmp"; | |
| std::ofstream tmp_file(tmp_path, std::ios::binary | std::ios::trunc); | |
| if (!tmp_file) { | |
| TSWarning("cripts::Cache::Group: Failed to open temp file for sync: %s.", tmp_path.c_str()); | |
| return false; | |
| } | |
| auto _appendToBuffer = [&](const void *data, size_t size) { | |
| if (write_failed) { | |
| return; | |
| } | |
| if (buf_pos + size > buffer.size()) { | |
| tmp_file.write(reinterpret_cast<const char *>(buffer.data()), buf_pos); | |
| if (!tmp_file) { | |
| write_failed = true; | |
| return; | |
| } | |
| buf_pos = 0; | |
| } | |
| std::memcpy(buffer.data() + buf_pos, static_cast<const std::byte *>(data), size); | |
| buf_pos += size; | |
| }; | |
| _MapHeader header{.created_ts = cripts::Time::Clock::to_time_t(slot.created), | |
| .last_write_ts = cripts::Time::Clock::to_time_t(slot.last_write), | |
| .last_sync_ts = cripts::Time::Clock::to_time_t(slot.last_sync), | |
| .count = slot.map->size()}; | |
| _appendToBuffer(&VERSION, sizeof(VERSION)); | |
| _appendToBuffer(&header, sizeof(header)); | |
| for (const auto &[_, entry] : *slot.map) { | |
| _appendToBuffer(&entry, sizeof(entry)); | |
| } | |
| if (buf_pos > 0 && !write_failed) { | |
| tmp_file.write(reinterpret_cast<const char *>(buffer.data()), buf_pos); | |
| } | |
| tmp_file.flush(); | |
| tmp_file.close(); | |
| if (write_failed || !tmp_file) { | |
| TSWarning("cripts::Cache::Group: Failed to write to temp file `%s'.", tmp_path.c_str()); | |
| std::error_code ec; | |
| std::filesystem::remove(tmp_path, ec); | |
| return false; | |
| } | |
| if (std::rename(tmp_path.c_str(), slot.path.c_str()) != 0) { | |
| TSWarning("cripts::Cache::Group: Failed to rename temp file `%s' to `%s'.", tmp_path.c_str(), slot.path.c_str()); | |
| std::error_code ec; | |
| std::filesystem::remove(tmp_path, ec); | |
| return false; | |
| } | |
| return true; | |
| } | |
| void | |
| Cache::Group::clearLog() | |
| { | |
| std::error_code ec; | |
| _txn_log.close(); | |
| std::filesystem::remove(_log_path, ec); | |
| if (ec) { | |
| TSWarning("cripts::Cache::Group: Failed to clear transaction log `%s': %s", _log_path.c_str(), ec.message().c_str()); | |
| } | |
| } | |
| // Singleton instance for the Cache::Group::Manager | |
| Cache::Group::Manager & | |
| Cache::Group::Manager::_instance() | |
| { | |
| static Cache::Group::Manager inst; | |
| return inst; | |
| } | |
| void * | |
| Cache::Group::Manager::Factory(const std::string &name, size_t max_entries, size_t num_maps) | |
| { | |
| std::lock_guard lock(_instance()._mutex); | |
| auto &groups = _instance()._groups; | |
| if (auto it = groups.find(name); it != groups.end()) { | |
| if (auto group = it->second.lock()) { | |
| return new std::shared_ptr<Group>(std::move(group)); | |
| } | |
| } | |
| if (!_instance()._base_dir.empty()) { | |
| auto group = std::make_shared<Group>(name, _instance()._base_dir, max_entries, num_maps); | |
| groups[name] = group; | |
| return new std::shared_ptr<Group>(std::move(group)); | |
| } else { | |
| TSError("cripts::Cache::Group: Failed to get runtime directory for initialization."); | |
| return nullptr; | |
| } | |
| } | |
| void | |
| Cache::Group::Manager::_scheduleCont() | |
| { | |
| if (!_cont) { | |
| _cont = TSContCreate(_cripts_cache_group_sync, TSMutexCreate()); | |
| TSContDataSet(_cont, this); | |
| } | |
| if (_action) { | |
| TSActionCancel(_action); | |
| _action = nullptr; | |
| } | |
| _action = TSContScheduleEveryOnPool(_cont, _CONT_SYNC_INTERVAL * 1000, TS_THREAD_POOL_TASK); | |
| } | |
| } // namespace cripts |