| // Copyright 2025 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| #include "components/persistent_cache/persistent_cache_collection.h" | |
| #include <stddef.h> | |
| #include <optional> | |
| #include <utility> | |
| #include <vector> | |
| #include "base/containers/fixed_flat_map.h" | |
| #include "base/containers/fixed_flat_set.h" | |
| #include "base/containers/map_util.h" | |
| #include "base/containers/span.h" | |
| #include "base/feature.h" | |
| #include "base/feature_list.h" | |
| #include "base/strings/string_util.h" | |
| #include "base/types/expected.h" | |
| #include "base/types/expected_macros.h" | |
| #include "components/persistent_cache/backend_storage.h" | |
| #include "components/persistent_cache/backend_type.h" | |
| #include "components/persistent_cache/lock_state.h" | |
| #include "components/persistent_cache/pending_backend.h" | |
| #include "components/persistent_cache/persistent_cache.h" | |
| #include "components/persistent_cache/transaction_error.h" | |
| namespace persistent_cache { | |
| namespace { | |
| // Enables WAL-mode for databases created in a PersistentCacheCollection. | |
| BASE_FEATURE(kPersistentCacheCollectionWalMode, | |
| base::FEATURE_DISABLED_BY_DEFAULT); | |
| } // namespace | |
| PersistentCacheCollection::PersistentCacheCollection( | |
| base::FilePath top_directory, | |
| int64_t target_footprint, | |
| Client client, | |
| size_t lru_capacity) | |
| : backend_storage_(client, BackendType::kSqlite, std::move(top_directory)), | |
| target_footprint_(target_footprint), | |
| lru_capacity_(lru_capacity), | |
| persistent_caches_(PersistentCacheLRUMap::NO_AUTO_EVICT) { | |
| ReduceFootPrint(); | |
| } | |
| PersistentCacheCollection::PersistentCacheCollection( | |
| base::FilePath top_directory, | |
| int64_t target_footprint, | |
| std::unique_ptr<BackendStorage::Delegate> storage_delegate, | |
| Client client, | |
| size_t lru_capacity) | |
| : backend_storage_(client, | |
| std::move(storage_delegate), | |
| std::move(top_directory)), | |
| target_footprint_(target_footprint), | |
| lru_capacity_(lru_capacity), | |
| persistent_caches_(PersistentCacheLRUMap::NO_AUTO_EVICT) { | |
| ReduceFootPrint(); | |
| } | |
| PersistentCacheCollection::~PersistentCacheCollection() = default; | |
| base::expected<std::optional<EntryMetadata>, TransactionError> | |
| PersistentCacheCollection::Find(const std::string& cache_id, | |
| base::span<const uint8_t> key, | |
| BufferProvider buffer_provider) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| auto* cache = GetOrCreateCache(cache_id); | |
| if (!cache) { | |
| return base::unexpected(TransactionError::kPermanent); | |
| } | |
| ASSIGN_OR_RETURN(auto entry, cache->Find(key, buffer_provider), | |
| [&cache_id, this](TransactionError error) { | |
| return HandleTransactionError(cache_id, error); | |
| }); | |
| return entry; | |
| } | |
| base::expected<void, TransactionError> PersistentCacheCollection::Insert( | |
| const std::string& cache_id, | |
| base::span<const uint8_t> key, | |
| base::span<const uint8_t> content, | |
| EntryMetadata metadata) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| // Approximate the footprint of this insert to the size of the key and value | |
| // combined. This is optimistic in some ways since it doesn't account for any | |
| // overhead and pessimimistic as it assumes every single write is both new and | |
| // doesn't evict something else. | |
| bytes_until_footprint_reduction_ -= key.size() + content.size(); | |
| if (bytes_until_footprint_reduction_ <= 0) { | |
| ReduceFootPrint(); | |
| } | |
| auto* cache = GetOrCreateCache(cache_id); | |
| if (!cache) { | |
| return base::unexpected(TransactionError::kPermanent); | |
| } | |
| RETURN_IF_ERROR(cache->Insert(key, content, metadata), | |
| [&cache_id, this](TransactionError error) { | |
| return HandleTransactionError(cache_id, error); | |
| }); | |
| return base::ok(); | |
| } | |
| void PersistentCacheCollection::DeleteAllFiles() { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| // Delete all files. Backends open all files with FLAG_WIN_SHARE_DELETE so | |
| // that they can be deleted even while open. Doing this before closing them | |
| // avoids a race condition where a scanner may try to open written-to files | |
| // immediately after they have been closed. | |
| backend_storage_.DeleteAllFiles(); | |
| // Clear all managed persistent caches so that they close their files, thereby | |
| // allowing them to be deleted. | |
| Clear(); | |
| } | |
| void PersistentCacheCollection::AbandonCache( | |
| const std::string& cache_id, | |
| PersistentCache* persistent_cache) { | |
| if (persistent_cache->Abandon() != LockState::kNotHeld) { | |
| // Some connections had a view of the files while abandoning. These could | |
| // be zombie connections but more importantly hung clients that will | |
| // eventually attempt to use the data again on resuming. Unlink files so | |
| // clients retain a stable view of them and new files with the same name | |
| // can be recreated if needed. | |
| backend_storage_.DeleteFiles(BaseNameFromCacheId(cache_id)); | |
| } | |
| } | |
| std::optional<PendingBackend> | |
| PersistentCacheCollection::ShareReadOnlyConnection( | |
| const std::string& cache_id) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| if (auto* cache = GetOrCreateCache(cache_id); cache) { | |
| return backend_storage_.ShareReadOnlyConnection( | |
| BaseNameFromCacheId(cache_id), *cache); | |
| } | |
| return std::nullopt; | |
| } | |
| std::optional<PendingBackend> | |
| PersistentCacheCollection::ShareReadWriteConnection( | |
| const std::string& cache_id) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| if (auto* cache = GetOrCreateCache(cache_id); cache) { | |
| return backend_storage_.ShareReadWriteConnection( | |
| BaseNameFromCacheId(cache_id), *cache); | |
| } | |
| return std::nullopt; | |
| } | |
| TransactionError PersistentCacheCollection::HandleTransactionError( | |
| const std::string& cache_id, | |
| TransactionError error) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| auto it = persistent_caches_.Get(cache_id); | |
| CHECK(it != persistent_caches_.end()); | |
| switch (error) { | |
| case TransactionError::kTransient: | |
| // Nothing to do in the case of transient errors but report them. | |
| break; | |
| case TransactionError::kConnectionError: | |
| // Remove from opened caches to force re-opening since the files are | |
| // presumed still valid. | |
| AbandonCache(it->first, it->second.get()); | |
| persistent_caches_.Erase(it); | |
| break; | |
| case TransactionError::kPermanent: | |
| // Delete files as they cannot be used again. Purposefully done before | |
| // erasing the instance to avoid races. See `DeleteAllFiles()` for | |
| // details. | |
| backend_storage_.DeleteFiles(BaseNameFromCacheId(cache_id)); | |
| // Remove from opened caches since the backing files don't exist anymore. | |
| AbandonCache(it->first, it->second.get()); | |
| persistent_caches_.Erase(it); | |
| break; | |
| } | |
| return error; | |
| } | |
| void PersistentCacheCollection::ReduceFootPrint() { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| // Clear all managed persistent caches so they don't hold on to files or | |
| // prevent their deletion. | |
| Clear(); | |
| // Reducing the footprint of the collection to exactly the desired target | |
| // could have the effect of rapidly going over the limit again. This might end | |
| // up issuing more reductions than desirable. This defines some headroom to | |
| // try and mitigate the issue. | |
| constexpr double kFootPrintReductionFactor = 0.90; | |
| auto adjusted_target = target_footprint_ * kFootPrintReductionFactor; | |
| auto current_footprint = | |
| backend_storage_.BringDownTotalFootprintOfFiles(adjusted_target) | |
| .current_footprint; | |
| bytes_until_footprint_reduction_ = target_footprint_ - current_footprint; | |
| } | |
| PersistentCache* PersistentCacheCollection::GetOrCreateCache( | |
| const std::string& cache_id) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| auto it = persistent_caches_.Get(cache_id); | |
| // If the cache is already created. | |
| if (it != persistent_caches_.end()) { | |
| return it->second.get(); | |
| } | |
| // The cache would exceed capacity on next insert. Remove the oldest entry to | |
| // make room. | |
| if (persistent_caches_.size() == lru_capacity_) { | |
| // Get the oldest entry iterator; | |
| auto oldest_it = persistent_caches_.rbegin(); | |
| AbandonCache(oldest_it->first, oldest_it->second.get()); | |
| persistent_caches_.Erase(oldest_it); | |
| } | |
| const base::FilePath base_name = BaseNameFromCacheId(cache_id); | |
| if (base_name.empty()) { | |
| return nullptr; // `cache_id` contains invalid characters. | |
| } | |
| ASSIGN_OR_RETURN( | |
| auto backend, | |
| backend_storage_.MakeBackend( | |
| base_name, /*single_connection=*/false, | |
| /*journal_mode_wal=*/ | |
| base::FeatureList::IsEnabled(kPersistentCacheCollectionWalMode)), | |
| [this, &base_name](TransactionError error) { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| // Failed to open/create the backend's files or bind to them. | |
| if (error == TransactionError::kPermanent) { | |
| // Delete the files since they are unusable. A future attempt to | |
| // create this same cache has a chance to succeed. | |
| backend_storage_.DeleteFiles(base_name); | |
| } | |
| return nullptr; | |
| }); | |
| // Create the cache | |
| auto inserted_it = persistent_caches_.Put( | |
| cache_id, std::make_unique<PersistentCache>(backend_storage_.client(), | |
| std::move(backend))); | |
| return inserted_it->second.get(); | |
| } | |
| void PersistentCacheCollection::Clear() { | |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); | |
| for (auto& [cache_id, cache] : persistent_caches_) { | |
| AbandonCache(cache_id, cache.get()); | |
| } | |
| persistent_caches_.Clear(); | |
| } | |
| namespace { | |
| // All characters allowed in filenames. | |
| constexpr auto kAllowedCharsInFilenames = base::MakeFixedFlatSet<char>( | |
| base::sorted_unique, | |
| {' ', '!', '#', '$', '&', '\'', '(', ')', '+', ',', '-', '.', '0', '1', | |
| '2', '3', '4', '5', '6', '7', '8', '9', ';', '=', '@', '[', ']', '_', | |
| 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', | |
| 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '~'}); | |
| static_assert(kAllowedCharsInFilenames.size() < 128, | |
| "Allowed chars are a subset of ASCII and overflow while " | |
| "indexing should never be a worry"); | |
| // Returns an arbitrary character at a fixed offset from `c` in the dictionary | |
| // above, or an empty value if not present in the dictionary. | |
| std::optional<char> RotateChar(char c) { | |
| auto char_iter = kAllowedCharsInFilenames.find(c); | |
| // Characters illegal in filenames are not handled in this function. | |
| if (char_iter == kAllowedCharsInFilenames.end()) { | |
| return std::nullopt; | |
| } | |
| auto char_index = char_iter - kAllowedCharsInFilenames.begin(); | |
| // Arbitrary offset to rotate index in the list of allowed characters. | |
| static constexpr int kRotationOffset = 37; | |
| // Use a rotating index to find a character to replace `c`. | |
| auto target_index = | |
| (char_index + kRotationOffset) % kAllowedCharsInFilenames.size(); | |
| return *(kAllowedCharsInFilenames.begin() + target_index); | |
| } | |
| // Mapping of characters illegal in filenames to a unique token to represent | |
| // them in filenames. This prevents collisions by avoiding mapping two | |
| // characters to the same value. Ex: | |
| // "*/" --> "`9`2" | |
| // "><" --> "`5`4" | |
| // | |
| // Mapping both strings to "`1`1" for example would result in a valid filename | |
| // but in backing files being shared for two keys which is not correct. | |
| constexpr auto kCharacterToTokenMap = | |
| base::MakeFixedFlatMap<char, std::string_view>({{'\\', "`1"}, | |
| {'/', "`2"}, | |
| {'|', "`3"}, | |
| {'<', "`4"}, | |
| {'>', "`5"}, | |
| {':', "`6"}, | |
| {'\"', "`7"}, | |
| {'?', "`8"}, | |
| {'*', "`9"}, | |
| {'\n', "`0"}, | |
| {'%', "`p"}, | |
| {'{', "`l"}, | |
| {'}', "`r"}, | |
| {'`', "`g"}}); | |
| // Returns a token uniquely representing a character `c` that is not legal in | |
| // filenames, or an empty string if no such replacement is available. | |
| std::string_view FilenameIllegalCharToReplacementToken(char c) { | |
| if (const auto* value = base::FindOrNull(kCharacterToTokenMap, c); value) { | |
| return *value; | |
| } | |
| return {}; | |
| } | |
| } // namespace | |
| // static | |
| base::FilePath PersistentCacheCollection::BaseNameFromCacheId( | |
| const std::string& cache_id) { | |
| std::string filename; | |
| // Optimistically reserve enough space assuming there are no illegal | |
| // characters in `cache_id`. | |
| filename.reserve(cache_id.size()); | |
| for (char c : cache_id) { | |
| if (auto rotated_char = RotateChar(c); rotated_char.has_value()) { | |
| filename.push_back(*rotated_char); | |
| } else if (auto token = FilenameIllegalCharToReplacementToken(c); | |
| !token.empty()) { | |
| filename += token; | |
| } else { | |
| // There's no way to rotate an illegal character so return an empty | |
| // path. | |
| return base::FilePath(); | |
| } | |
| } | |
| return base::FilePath::FromASCII(std::move(filename)); | |
| } | |
| // static | |
| std::string PersistentCacheCollection::GetAllAllowedCharactersInCacheIds() { | |
| std::string result; | |
| for (auto c : kAllowedCharsInFilenames) { | |
| result.push_back(c); | |
| } | |
| for (const auto& [c, replacement] : kCharacterToTokenMap) { | |
| result.push_back(c); | |
| } | |
| return result; | |
| } | |
| } // namespace persistent_cache |