From 12fab6cf6211c749f819e4c881f48a562d85f2ab Mon Sep 17 00:00:00 2001 From: Maxim Kozlovsky <10603239+maxkozlovsky@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:25:31 -0400 Subject: [PATCH] Add monad-mpt --rescan-devices Growing a node's database meant --archive then --restore into a freshly created larger pool: scratch space, plus a full dump and restore. Extending the device the pool owns was worse than unsupported -- the metadata saying a device belongs to a pool lives at its very end, so lvextend left it unopenable, with no resize2fs step to follow. --rescan-devices is that step: one offline run takes up the space an extended device now offers, under one confirmation and one metadata growth. Taking up an extend needs the previous size of the device, which the extend itself strands mid-device, so db_metadata now records that size on each writable open, in padding it already reserves -- no magic bump, no migration. That recording is the only source, and is acted on only where the stranded footer's config_hash confirms it, so an extend must be preceded by a writable open under a release carrying this code. With one device the relocated footer is the whole commit record: there is no sibling whose config_hash has to be durable first, so a crash at any point either leaves the old footer stranded and the run repeatable, or the new one in place and the run complete. Co-Authored-By: Claude Opus 5 (1M context) --- category/async/storage_pool.cpp | 581 ++++++++++++++++-- category/async/storage_pool.hpp | 211 ++++++- category/async/test/storage_pool.cpp | 310 ++++++++++ .../async/test/storage_pool_test_access.hpp | 86 +++ category/mpt/cli_tool_impl.cpp | 190 +++++- category/mpt/db_metadata_context.cpp | 167 ++++- category/mpt/db_metadata_context.hpp | 38 +- category/mpt/detail/db_metadata.hpp | 102 ++- category/mpt/test/CMakeLists.txt | 9 + category/mpt/test/cli_tool_test.cpp | 399 ++++++++++++ category/mpt/test/db_metadata_test.cpp | 13 +- category/mpt/test/db_metadata_test_access.hpp | 13 + category/mpt/test/rescan_death_no_mode.cpp | 85 +++ category/mpt/test/rescan_devices_test.cpp | 418 +++++++++++++ .../mpt/test/rescan_devices_test_util.hpp | 151 +++++ category/mpt/test/update_aux_test.cpp | 9 +- category/mpt/update_aux.cpp | 11 +- 17 files changed, 2725 insertions(+), 68 deletions(-) create mode 100644 category/async/test/storage_pool_test_access.hpp create mode 100644 category/mpt/test/rescan_death_no_mode.cpp create mode 100644 category/mpt/test/rescan_devices_test.cpp create mode 100644 category/mpt/test/rescan_devices_test_util.hpp diff --git a/category/async/storage_pool.cpp b/category/async/storage_pool.cpp index 71ecb9aa2d..155ea01fe0 100644 --- a/category/async/storage_pool.cpp +++ b/category/async/storage_pool.cpp @@ -34,8 +34,10 @@ #include #include #include +#include #include #include +#include #include @@ -55,6 +57,423 @@ MONAD_ASYNC_NAMESPACE_BEGIN // such pools were always carved with this many conventional chunks. static constexpr uint32_t legacy_default_num_cnv_chunks = 3; +uint64_t storage_pool::compute_unique_hash_( + device_t::type_t_ const type, uint64_t const dev_no, + file_offset_t const size) +{ + auto hash = fnv1a_hash::begin(); + fnv1a_hash::add(hash, uint32_t(type)); + fnv1a_hash::add(hash, uint32_t(dev_no)); + fnv1a_hash::add(hash, uint32_t(dev_no >> 32)); + fnv1a_hash::add(hash, uint32_t(size)); + return hash; +} + +storage_pool::device_info_ +storage_pool::read_device_info_(std::filesystem::path const &source) +{ + int const fd = ::open(source.c_str(), O_RDONLY | O_CLOEXEC); + MONAD_ASSERT_PRINTF( + fd != -1, + "open of %s failed due to %s", + source.string().c_str(), + std::strerror(errno)); + auto const unfd = make_scope_exit([fd]() noexcept { ::close(fd); }); + struct stat stat; + memset(&stat, 0, sizeof(stat)); + MONAD_ASSERT_PRINTF( + -1 != ::fstat(fd, &stat), + "fstat failed due to %s", + std::strerror(errno)); + device_info_ ret{}; + if ((stat.st_mode & S_IFMT) == S_IFBLK) { + ret.type = device_t::type_t_::block_device; + MONAD_ASSERT_PRINTF( + !ioctl(fd, _IOR(0x12, 114, size_t) /*BLKGETSIZE64*/, &ret.size), + "ioctl failed due to %s", + std::strerror(errno)); + ret.hash_dev_no = 0; + } + else if ((stat.st_mode & S_IFMT) == S_IFREG) { + ret.type = device_t::type_t_::file; + ret.hash_dev_no = static_cast(stat.st_ino); + ret.size = static_cast(stat.st_size); + } + else { + MONAD_ABORT_PRINTF( + "Storage pool source %s has unknown file entry type = %u", + source.string().c_str(), + stat.st_mode & S_IFMT); + } + MONAD_ASSERT_PRINTF( + ret.size >= CPU_PAGE_SIZE, + "Storage pool source %s must be at least 4Kb long", + source.string().c_str()); + ret.unique_hash = compute_unique_hash_(ret.type, ret.hash_dev_no, ret.size); + + auto *const buffer = reinterpret_cast( + aligned_alloc(DISK_PAGE_SIZE, DISK_PAGE_SIZE * 2)); + MONAD_ASSERT(buffer != nullptr); + auto const unbuffer = make_scope_exit([&]() noexcept { ::free(buffer); }); + auto const offset = round_down_align( + ret.size - sizeof(device_t::metadata_t)); + auto const bytesread = ::pread( + fd, + buffer, + static_cast(ret.size - offset), + static_cast(offset)); + MONAD_ASSERT_PRINTF( + bytesread != -1, "pread failed due to %s", std::strerror(errno)); + // The footer is located from the byte count, so a short read would point + // it at the wrong bytes -- and at zero bytes, before the buffer entirely. + // What those bytes say is whether this device belongs to a pool. + MONAD_ASSERT_PRINTF( + static_cast(bytesread) == ret.size - offset, + "read %zd of %llu bytes of %s's pool footer", + bytesread, + static_cast(ret.size - offset), + source.string().c_str()); + auto const *const footer = start_lifetime_as( + buffer + bytesread - sizeof(device_t::metadata_t)); + if (memcmp(footer->magic, "MND0", 4) == 0) { + ret.pool_metadata = device_pool_metadata_{ + .chunk_capacity = footer->chunk_capacity, + .num_cnv_chunks = footer->num_cnv_chunks == 0 + ? legacy_default_num_cnv_chunks + : footer->num_cnv_chunks, + .config_hash = footer->config_hash, + .chunks = footer->chunks(ret.size)}; + } + return ret; +} + +storage_pool::rescan_preview storage_pool::preview_rescan( + std::filesystem::path const &source, + std::optional const recorded_size, + std::optional const &budget) +{ + auto const info = read_device_info_(source); + rescan_preview ret{}; + if (auto const grown = + validate_device_to_rescan_(source, info, recorded_size, budget)) { + ret.grown_previous_size = grown->previous_size; + ret.grown_previous_chunks = grown->previous_chunks; + } + return ret; +} + +uint32_t storage_pool::compute_config_hash_(device_info_ const &device) +{ + auto hash = fnv1a_hash::begin(); + fnv1a_hash::add(hash, uint32_t(device.unique_hash)); + fnv1a_hash::add(hash, uint32_t(device.unique_hash >> 32)); + auto const &metadata = device.pool_metadata.value(); + fnv1a_hash::add(hash, static_cast(metadata.chunks)); + fnv1a_hash::add(hash, metadata.chunk_capacity); + return uint32_t(hash); +} + +auto storage_pool::read_footer_for_size_(int const fd, file_offset_t const size) + -> std::optional +{ + if (size < sizeof(device_t::metadata_t)) { + return std::nullopt; + } + device_t::metadata_t footer{}; + auto const bytesread = ::pread( + fd, &footer, sizeof(footer), static_cast(size - sizeof(footer))); + MONAD_ASSERT_PRINTF( + bytesread != -1, "pread failed due to %s", std::strerror(errno)); + if (static_cast(bytesread) != sizeof(footer) || + memcmp(footer.magic, "MND0", 4) != 0) { + return std::nullopt; + } + return footer; +} + +storage_pool::device_info_ storage_pool::device_info_at_previous_size_( + device_info_ const &now, grown_device_ const &grown) +{ + device_info_ ret = now; + ret.size = grown.previous_size; + ret.pool_metadata = device_pool_metadata_{ + .chunk_capacity = grown.chunk_capacity, + .num_cnv_chunks = grown.num_cnv_chunks, + // The stranded footer's own config_hash is what the caller compares + // against, so it is deliberately not carried over here. + .config_hash = 0, + .chunks = grown.previous_chunks}; + ret.unique_hash = + compute_unique_hash_(now.type, now.hash_dev_no, grown.previous_size); + return ret; +} + +auto storage_pool::validate_grown_device_( + std::filesystem::path const &source, device_info_ const ¤t, + std::optional const recorded_size, bool &footer_found) + -> std::optional +{ + footer_found = false; + if (!recorded_size.has_value() || *recorded_size >= current.size || + *recorded_size < CPU_PAGE_SIZE) { + return std::nullopt; + } + int const fd = ::open(source.c_str(), O_RDONLY | O_CLOEXEC); + MONAD_ASSERT_PRINTF( + fd != -1, + "open of %s failed due to %s", + source.string().c_str(), + std::strerror(errno)); + auto const unfd = make_scope_exit([fd]() noexcept { ::close(fd); }); + auto const footer = read_footer_for_size_(fd, *recorded_size); + if (!footer.has_value()) { + return std::nullopt; + } + footer_found = true; + auto const capacity = footer->chunk_capacity; + if (capacity == 0 || (capacity & (capacity - 1)) != 0) { + return std::nullopt; + } + auto const cnv_chunks = footer->num_cnv_chunks == 0 + ? legacy_default_num_cnv_chunks + : footer->num_cnv_chunks; + auto const previous_chunks = footer->chunks(*recorded_size); + if (previous_chunks < cnv_chunks + 1u) { + return std::nullopt; + } + grown_device_ const candidate{ + .previous_size = *recorded_size, + .previous_chunks = previous_chunks, + .chunk_capacity = capacity, + .num_cnv_chunks = cnv_chunks}; + // The hash the device must have produced before it grew is the one the + // stranded footer itself stores. unique_hash folds the device's size, so + // the recomputed hash depends on the recorded size -- through the chunk + // count it is folded into, since the hash itself folds only its low 32 + // bits -- so this pins + // the previous size rather than merely narrowing it, and four bytes + // spelling MND0 in trie data cannot pass it. The footer predates the grow + // and this operation never rewrites it, so it stays a fixed point to check + // against even across an interrupted run. + if (compute_config_hash_(device_info_at_previous_size_( + current, candidate)) != footer->config_hash) { + return std::nullopt; + } + return candidate; +} + +auto storage_pool::validate_device_to_rescan_( + std::filesystem::path const &source, device_info_ const &info, + std::optional const recorded_size, + std::optional const &budget) + -> std::optional +{ + std::optional grown; + if (!info.pool_metadata.has_value()) { + // No footer at the end this size gives it, so either an extend + // stranded it mid-device or this is not the pool's device at all. + bool footer_at_recorded_size = false; + grown = validate_grown_device_( + source, info, recorded_size, footer_at_recorded_size); + MONAD_ASSERT_PRINTF( + recorded_size.has_value(), + "Storage pool source %s carries no pool metadata at its end. If it " + "was extended in place, the database holds no record of the size " + "it had beforehand, which is the only thing that can locate the " + "metadata the extend stranded; that size is recorded on every " + "writable open, so a database last written by a release which did " + "not record it must be opened writable once before its device is " + "extended. Return the device to its former size to reopen the " + "database, or restore from a monad-mpt --archive.", + source.string().c_str()); + MONAD_ASSERT_PRINTF( + !footer_at_recorded_size || grown.has_value(), + "Storage pool source %s was extended in place from the %llu bytes " + "the database recorded for it, but the pool metadata stranded " + "there describes a different pool, so this is not the device the " + "database was last opened with. Restore the original device.", + source.string().c_str(), + static_cast(*recorded_size)); + MONAD_ASSERT_PRINTF( + grown.has_value(), + "Storage pool source %s carries no pool metadata at its end, nor " + "any at the %llu bytes the database recorded for it. If it was " + "extended in place, it is not the device the database was last " + "opened with.", + source.string().c_str(), + static_cast(*recorded_size)); + } + else if (info.pool_metadata->config_hash != 0) { + // The device was not extended, so the footer at its end is the one to + // rescan against. adopt_device_ would reject a foreign one, but only + // after the operator has confirmed: this function is what a caller + // puts in front of that prompt, so the same refusal belongs here. A + // zero hash is a pool which has never been adopted, which adopt_device_ + // stamps rather than refuses. + MONAD_ASSERT_PRINTF( + info.pool_metadata->config_hash == compute_config_hash_(info), + "Storage pool source %s carries pool metadata at its end which was " + "initialised with a configuration different to this storage pool, " + "so this is not the device the database was last opened with.", + source.string().c_str()); + } + uint32_t const chunk_capacity = grown.has_value() + ? grown->chunk_capacity + : info.pool_metadata->chunk_capacity; + uint32_t const cnv_chunks = grown.has_value() + ? grown->num_cnv_chunks + : info.pool_metadata->num_cnv_chunks; + device_t::metadata_t probe{}; + probe.chunk_capacity = chunk_capacity; + size_t const total_chunks = probe.chunks(info.size); + MONAD_ASSERT_PRINTF( + total_chunks > cnv_chunks, + "Storage pool source %s offers %zu chunks, fewer than the %u " + "conventional chunks the pool reserves.", + source.string().c_str(), + total_chunks, + cnv_chunks); + size_t const total_seq_chunks = total_chunks - cnv_chunks; + + // The relocation writes the new metadata region before the old one is + // superseded, so the two must not overlap: writing the new bytes-used + // array over the old one would destroy the only record of how full each + // existing chunk is while the new footer is not yet durable. Refusing a + // growth too small to clear the old region turns that crash window into an + // input check, and rejects nothing useful, since a growth that small + // yields no new chunks anyway. + if (grown.has_value()) { + size_t const region = + sizeof(device_t::metadata_t) + total_chunks * sizeof(uint32_t); + MONAD_ASSERT_PRINTF( + info.size >= grown->previous_size + region, + "Storage pool source %s grew from %llu to %llu bytes, but its new " + "metadata occupies %zu bytes and would overwrite the metadata " + "being recovered. Extend it by at least %llu more bytes and " + "re-run.", + source.string().c_str(), + static_cast(grown->previous_size), + static_cast(info.size), + region, + static_cast( + grown->previous_size + region - info.size)); + } + + // Both budgets are checked here so an over-large device is refused before + // a footer is written; the layer that owns the metadata layout cannot do + // it for itself, because by the time it opens the footer is already + // committed. + // + // chunk_info_count is a 20 bit field, and its top value is the sentinel + // the database's free list terminates on, so a count of 0x100000 would + // both overflow the field and produce an id indistinguishable from an + // absent link. + MONAD_ASSERT_PRINTF( + total_seq_chunks <= chunk_offset_t::max_id, + "Taking up this device would give the pool %zu sequential chunks, " + "beyond the %llu the 20 bit chunk id space allows. Use a smaller " + "device.", + total_seq_chunks, + static_cast(chunk_offset_t::max_id)); + size_t const metadata_bytes_needed = + budget.has_value() + ? budget->header_bytes + total_seq_chunks * budget->bytes_per_chunk + : 0; + size_t const metadata_bytes_available = chunk_capacity / 2; + MONAD_ASSERT_PRINTF( + !budget.has_value() || + metadata_bytes_available >= metadata_bytes_needed, + "Taking up this device would give the pool %zu sequential chunks, " + "needing %zu bytes of database metadata, but conventional chunk 0 on " + "%s only provides %zu. This pool's chunk capacity is too small to " + "describe that many chunks; use a smaller device.", + total_seq_chunks, + metadata_bytes_needed, + source.string().c_str(), + metadata_bytes_available); + return grown; +} + +void storage_pool::relocate_device_metadata_( + std::filesystem::path const &source, file_offset_t const current_size, + grown_device_ const &grown, uint32_t const new_config_hash) +{ + int const fd = ::open(source.c_str(), O_RDWR | O_CLOEXEC); + MONAD_ASSERT_PRINTF( + fd != -1, + "open of %s failed due to %s", + source.string().c_str(), + std::strerror(errno)); + auto const unfd = make_scope_exit([fd]() noexcept { ::close(fd); }); + + device_t::metadata_t footer{}; + footer.chunk_capacity = grown.chunk_capacity; + footer.num_cnv_chunks = grown.num_cnv_chunks; + footer.config_hash = new_config_hash; + // chunks() carries a correction which drops the last chunk when the + // metadata region would otherwise collide with it, so it must be called + // rather than reimplemented. + auto const new_chunks = footer.chunks(current_size); + MONAD_ASSERT(new_chunks >= grown.previous_chunks); + auto const array_bytes = new_chunks * sizeof(uint32_t); + auto const array_base = current_size - sizeof(footer) - array_bytes; + MONAD_ASSERT(array_base >= grown.previous_size); + + // The array is anchored to the footer and indexed upward from its base, + // so a larger chunk count moves it bodily downward while entry n keeps + // index n. Written whole rather than copied so that the entries the + // extend uncovered are zeroed instead of holding the old region's bytes. + std::vector bytes_used(new_chunks, 0); + auto const old_array_bytes = grown.previous_chunks * sizeof(uint32_t); + auto const old_array_base = + grown.previous_size - sizeof(footer) - old_array_bytes; + auto const bytesread = ::pread( + fd, + bytes_used.data(), + old_array_bytes, + static_cast(old_array_base)); + MONAD_ASSERT_PRINTF( + bytesread != -1, "pread failed due to %s", std::strerror(errno)); + MONAD_ASSERT_PRINTF( + static_cast(bytesread) == old_array_bytes, + "read %zd of %zu bytes of the stranded per-chunk bytes-used array " + "on %s", + bytesread, + old_array_bytes, + source.string().c_str()); + + MONAD_ASSERT_PRINTF( + ::pwrite( + fd, + bytes_used.data(), + array_bytes, + static_cast(array_base)) == ssize_t(array_bytes), + "pwrite failed due to %s", + std::strerror(errno)); + MONAD_ASSERT_PRINTF( + 0 == ::fdatasync(fd), + "fdatasync failed due to %s", + std::strerror(errno)); + + // The footer at the new end is what makes the device valid, so it commits + // the relocation. A crash before it is durable leaves the device still + // classified as grown, and re-running simply redoes the whole operation. + memcpy(footer.magic, "MND0", sizeof(footer.magic)); + MONAD_ASSERT_PRINTF( + ::pwrite( + fd, + &footer, + sizeof(footer), + static_cast(current_size - sizeof(footer))) == + ssize_t(sizeof(footer)), + "pwrite failed due to %s", + std::strerror(errno)); + MONAD_ASSERT_PRINTF( + 0 == ::fdatasync(fd), + "fdatasync failed due to %s", + std::strerror(errno)); +} + std::filesystem::path storage_pool::device_t::current_path() const { std::filesystem::path::string_type ret; @@ -76,6 +495,21 @@ std::filesystem::path storage_pool::device_t::current_path() const return ret; } +std::pair +storage_pool::device_t::metadata_mapping_() const noexcept +{ + auto const total_size = metadata_->total_size(size_of_file_); + auto const offset = + round_down_align(size_of_file_ - total_size); + auto const mapped_bytes = + round_up_align(size_of_file_ - offset); + auto const metadata_from_base = + static_cast(size_of_file_ - offset) - sizeof(metadata_t); + return { + reinterpret_cast(metadata_) - metadata_from_base, + static_cast(mapped_bytes)}; +} + size_t storage_pool::device_t::chunks() const { MONAD_ASSERT(!is_zoned_device(), "zonefs support isn't implemented yet"); @@ -328,12 +762,8 @@ storage_pool::device_t storage_pool::make_device_( { int readwritefd = fd; uint64_t const chunk_capacity = 1ULL << flags.chunk_capacity; - auto unique_hash = fnv1a_hash::begin(); - if (auto const *dev_no = std::get_if<0>(&dev_no_or_dev)) { - fnv1a_hash::add(unique_hash, uint32_t(type)); - fnv1a_hash::add(unique_hash, uint32_t(*dev_no)); - fnv1a_hash::add(unique_hash, uint32_t(*dev_no >> 32)); - } + uint64_t unique_hash = 0; + auto const *const dev_no = std::get_if<0>(&dev_no_or_dev); if (!path.empty()) { readwritefd = ::open( path.c_str(), @@ -373,8 +803,12 @@ storage_pool::device_t storage_pool::make_device_( "storage pool", path.string().c_str()); } - fnv1a_hash::add(unique_hash, uint32_t(stat.st_size)); + if (dev_no != nullptr) { + unique_hash = compute_unique_hash_( + type, *dev_no, static_cast(stat.st_size)); + } size_t total_size = 0; + bool freshly_initialised = false; { auto *const buffer = reinterpret_cast( aligned_alloc(DISK_PAGE_SIZE, DISK_PAGE_SIZE * 2)); @@ -395,11 +829,25 @@ storage_pool::device_t storage_pool::make_device_( buffer + bytesread - sizeof(device_t::metadata_t)); if (memcmp(metadata_footer->magic, "MND0", 4) != 0 || op == mode::truncate) { + freshly_initialised = true; // Uninitialised if (op == mode::open_existing) { MONAD_ABORT_PRINTF( - "Storage pool source %s has not been initialised " - "for use with storage pool", + "Storage pool source %s has not been initialised for use " + "with storage pool. A device extended in place also " + "presents this way, because the pool metadata is still at " + "the size the device had before: run monad-mpt " + "--rescan-devices on it to take up the new space.", + path.string().c_str()); + } + if (op == mode::rescan) { + // A rescan has either just relocated the footer to this + // device's end or found one already there, so reaching this + // means the device is not the one that was validated. Falling + // through would discard the database. + MONAD_ABORT_PRINTF( + "Storage pool source %s carries no pool metadata at its " + "end, so it is not the device the rescan validated", path.string().c_str()); } if (stat.st_size < (1LL << flags.chunk_capacity) + CPU_PAGE_SIZE) { @@ -504,7 +952,26 @@ storage_pool::device_t storage_pool::make_device_( type, unique_hash, static_cast(stat.st_size), - metadata); + metadata, + freshly_initialised); +} + +storage_pool::device_info_ storage_pool::device_info_of_(device_t const &device) +{ + MONAD_ASSERT( + device.is_file() || device.is_block_device(), + "zonefs support isn't implemented yet"); + device_info_ ret{}; + ret.type = device.type_; + ret.unique_hash = device.unique_hash_; + ret.size = device.size_of_file_; + // A live device always carries its footer. + ret.pool_metadata = device_pool_metadata_{ + .chunk_capacity = device.metadata_->chunk_capacity, + .num_cnv_chunks = static_cast(device.cnv_chunks()), + .config_hash = device.metadata_->config_hash, + .chunks = device.chunks()}; + return ret; } void storage_pool::adopt_device_(creation_flags const &flags) @@ -525,35 +992,33 @@ void storage_pool::adopt_device_(creation_flags const &flags) uint32_t const seq_chunks_count = static_cast(devicechunks) - cnv_chunks_count; - auto hashshouldbe = fnv1a_hash::begin(); - fnv1a_hash::add(hashshouldbe, uint32_t(device_.unique_hash_)); - fnv1a_hash::add( - hashshouldbe, uint32_t(device_.unique_hash_ >> 32)); - fnv1a_hash::add( - hashshouldbe, static_cast(devicechunks)); - fnv1a_hash::add(hashshouldbe, device_.metadata_->chunk_capacity); + // A rescan needs no case of its own: relocate_device_metadata_ wrote the + // footer at the device's new end carrying the hash this recomputes, so the + // ordinary check passes. + uint32_t const hashshouldbe = + compute_config_hash_(device_info_of_(device_)); if (device_.metadata_->config_hash == 0) { - device_.metadata_->config_hash = uint32_t(hashshouldbe); + device_.metadata_->config_hash = hashshouldbe; } - else if (device_.metadata_->config_hash != uint32_t(hashshouldbe)) { + else if (device_.metadata_->config_hash != hashshouldbe) { if (!flags.disable_mismatching_storage_pool_check) { MONAD_ABORT_PRINTF( "Storage pool source %s was initialised with a configuration " - "different to this storage pool. Has it been resized since the " - "pool was created?\n\nYou should use the monad-mpt tool to " - "copy and move databases around, NOT by copying partition " - "contents!", + "different to this storage pool. Was it resized without " + "running monad-mpt --rescan-devices?\n\nYou should use the " + "monad-mpt tool to copy and move databases around, NOT by " + "copying partition contents!", device_.current_path().c_str()); } else { MONAD_ABORT_PRINTF( "Storage pool source %s was initialised with a configuration " - "different to this storage pool. Has it been resized since the " - "pool was created?\n\nYou should use the monad-mpt tool to " - "copy and move databases around, NOT by copying partition " - "contents!\n\nSince the monad-mpt tool was added, the flag " - "disable_mismatching_storage_pool_check is no longer needed " - "and has been disabled.", + "different to this storage pool. Was it resized without " + "running monad-mpt --rescan-devices?\n\nYou should use the " + "monad-mpt tool to copy and move databases around, NOT by " + "copying partition contents!\n\nSince the monad-mpt tool was " + "added, the flag disable_mismatching_storage_pool_check is no " + "longer needed and has been disabled.", device_.current_path().c_str()); } } @@ -612,6 +1077,43 @@ storage_pool::device_t storage_pool::open_device_( std::filesystem::path const &source, mode const op, creation_flags const flags) { + // A grown device has no footer at the end its new size gives it, so the + // metadata has to be moved there before the device can be opened at all. + // Everything this needs is validated first, so a refused device is left + // untouched. + if (op == mode::rescan) { + MONAD_ASSERT( + !flags.open_read_only && !flags.open_read_only_allow_dirty, + "mode::rescan relocates the metadata of a grown device, so it " + "cannot be opened read only."); + // Without the budget, the only refusal that catches a device too large + // for the database's metadata comes after the footer has been + // relocated, which leaves the pool unopenable at any size but the one + // it had before. + MONAD_ASSERT( + flags.metadata_budget.has_value(), + "mode::rescan needs the owning layer's metadata budget to refuse " + "an over-large device before committing the relocation."); + auto const info = read_device_info_(source); + if (auto const grown = validate_device_to_rescan_( + source, + info, + flags.recorded_size_of_grown_device, + flags.metadata_budget)) { + device_t::metadata_t probe{}; + probe.chunk_capacity = grown->chunk_capacity; + device_info_ after = info; + after.pool_metadata = device_pool_metadata_{ + .chunk_capacity = grown->chunk_capacity, + .num_cnv_chunks = grown->num_cnv_chunks, + .config_hash = 0, + .chunks = probe.chunks(info.size)}; + // The relocated footer is the commit record, and with one device + // it is the only one: no sibling has to carry the new hash first. + relocate_device_metadata_( + source, info.size, *grown, compute_config_hash_(after)); + } + } int const fd = ::open(source.c_str(), O_PATH | O_CLOEXEC); MONAD_ASSERT_PRINTF( fd != -1, "open failed due to %s", std::strerror(errno)); @@ -668,6 +1170,7 @@ storage_pool::storage_pool( , is_read_only_allow_dirty_(false) , is_migration_allowed_(false) , is_newly_truncated_(false) + , is_rescanning_(false) , device_(reopen_device_read_only_(src->device_)) { creation_flags flags; @@ -676,13 +1179,17 @@ storage_pool::storage_pool( } storage_pool::storage_pool( - std::filesystem::path const &source, mode const mode, + std::filesystem::path const &source, mode const mode_, creation_flags const flags) : is_read_only_(flags.open_read_only || flags.open_read_only_allow_dirty) , is_read_only_allow_dirty_(flags.open_read_only_allow_dirty) , is_migration_allowed_(flags.allow_migration) - , is_newly_truncated_(mode == mode::truncate) - , device_(open_device_(source, mode, flags)) + // mode::rescan must never set this: DbMetadataContext zeroes both + // metadata magics when it is set, which would destroy the database it is + // meant to be growing. + , is_newly_truncated_(mode_ == mode::truncate) + , is_rescanning_(mode_ == mode::rescan) + , device_(open_device_(source, mode_, flags)) { adopt_device_(flags); } @@ -700,6 +1207,7 @@ storage_pool::storage_pool( , is_read_only_allow_dirty_(flags.open_read_only_allow_dirty) , is_migration_allowed_(flags.allow_migration) , is_newly_truncated_(false) + , is_rescanning_(false) , device_(make_anonymous_device_(len, flags)) { adopt_device_(flags); @@ -708,13 +1216,8 @@ storage_pool::storage_pool( storage_pool::~storage_pool() { if (device_.metadata_ != nullptr) { - auto const total_size = - device_.metadata_->total_size(device_.size_of_file_); - ::munmap( - reinterpret_cast(round_down_align( - (uintptr_t)device_.metadata_ + sizeof(device_t::metadata_t) - - total_size)), - total_size); + auto const mapping = device_.metadata_mapping_(); + ::munmap(mapping.first, mapping.second); } if (device_.readwritefd_ != -1) { (void)::fsync(device_.readwritefd_); diff --git a/category/async/storage_pool.hpp b/category/async/storage_pool.hpp index ec5a0bdfaa..4e44c575e3 100644 --- a/category/async/storage_pool.hpp +++ b/category/async/storage_pool.hpp @@ -22,11 +22,17 @@ #include #include +#include #include #include MONAD_ASYNC_NAMESPACE_BEGIN +namespace test +{ + struct StoragePoolTestAccess; // test-only access to the hash formulae +} + /* \brief Makes available the lowest possible latency zoned storage, if `zonefs` is available. Otherwise falls back to an emulation which can use a file on a filesystem, or a block device. @@ -66,6 +72,8 @@ pathological i/o performance loss at usually the most inconvenient times. */ class storage_pool { + friend struct test::StoragePoolTestAccess; + public: //! \brief Type of chunk, conventional or sequential enum chunk_type @@ -144,21 +152,46 @@ class storage_pool } } *const metadata_; + // True if this open wrote the device's footer: the device was blank, + // or it was opened with mode::truncate. + bool const is_freshly_initialised_; + static_assert(sizeof(metadata_t) == 64); + // Base address and length of the mapping make_device_ established over + // this device's metadata. The base is CPU page aligned. + std::pair metadata_mapping_() const noexcept; + constexpr device_t( int const readwritefd, type_t_ const type, uint64_t const unique_hash, file_offset_t const size_of_file, - metadata_t *const metadata) + metadata_t *const metadata, bool const is_freshly_initialised) : readwritefd_(readwritefd) , type_(type) , unique_hash_(unique_hash) , size_of_file_(size_of_file) , metadata_(metadata) + , is_freshly_initialised_(is_freshly_initialised) { } public: + //! Returns whether this open wrote the device's footer, which it does + //! on a blank device and under mode::truncate + bool is_freshly_initialised() const noexcept + { + return is_freshly_initialised_; + } + + //! The size of the device in bytes, as of when this pool opened it. + //! This is the quantity chunks() and the device's unique_hash are + //! derived from, so it is what has to be recorded to later recover + //! the geometry of a device grown in place. + file_offset_t size_bytes() const noexcept + { + return size_of_file_; + } + //! The current filesystem path of the device (it can change over time) std::filesystem::path current_path() const; @@ -295,11 +328,46 @@ class storage_pool //! \brief What to do when opening the pool for use. enum class mode { + //! The source must already carry pool metadata; abort if it does + //! not. open_existing, + //! Initialise the source if it does not, otherwise open it as it is. create_if_needed, - truncate + //! Discard the source's contents and initialise it. + truncate, + //! Take up storage the source now offers but the pool does not yet + //! use, by relocating the metadata of a device extended in place. + //! Existing data is kept, and re-running resumes an interrupted run. + rescan }; + //! \brief How much space a database's metadata needs in the first half of + //! conventional chunk 0, supplied by the caller that owns that layout so + //! this layer needs no knowledge of it. + struct db_metadata_budget + { + //! Fixed header, ahead of the per-chunk array. Should be the largest + //! of any on-disk format the caller can still read, so a pool stays + //! migratable without remapping. + size_t header_bytes; + //! Cost of each sequential chunk in the per-chunk array. + size_t bytes_per_chunk; + + //! A pool with no database on it, which has nothing to fit. Spelled + //! out so that skipping the check is a decision rather than an + //! omission. + static constexpr db_metadata_budget no_database() noexcept + { + return {.header_bytes = 0, .bytes_per_chunk = 0}; + } + }; + + //! Smallest chunk capacity any pool carrying a database can have been + //! created with. This layer enforces no minimum of its own; the floor + //! comes from the database metadata having to fit in half of conventional + //! chunk 0, which the owning layer enforces on open. + static constexpr uint32_t min_chunk_capacity = 1u << 21; + //! \brief Flags for storage pool creation struct creation_flags { @@ -327,6 +395,14 @@ class storage_pool //! Number of conventional chunks to allocate. Default is 3. uint32_t num_cnv_chunks; + //! What db_metadata recorded as the size of the device before the + //! extend. + std::optional recorded_size_of_grown_device; + + //! Space the database's metadata needs, from the caller that owns that + //! layout. Nothing if there is no database. + std::optional metadata_budget; + constexpr creation_flags() : chunk_capacity(28) , open_read_only(false) @@ -334,6 +410,8 @@ class storage_pool , disable_mismatching_storage_pool_check(false) , allow_migration(false) , num_cnv_chunks(3) + , recorded_size_of_grown_device(std::nullopt) + , metadata_budget(std::nullopt) { } @@ -348,12 +426,107 @@ class storage_pool private: bool const is_read_only_, is_read_only_allow_dirty_, is_migration_allowed_, - is_newly_truncated_; + is_newly_truncated_, is_rescanning_; device_t device_; // A chunk's whole geometry follows from its id, so these counts are all // the pool keeps per chunk type. uint32_t cnv_chunks_count_{0}, seq_chunks_count_{0}; + // The pool metadata a device carries in its final sizeof(metadata_t) + // bytes, as read back. Absent on a blank device, and on one extended in + // place, which strands the footer mid-device. + struct device_pool_metadata_ + { + uint32_t chunk_capacity; + uint32_t num_cnv_chunks; + uint32_t config_hash; + size_t chunks; + }; + + // Read-only description of the source, gathered before anything is + // written, so a refused device is never modified. unique_hash is stored + // already-computed rather than as its inputs, so this can equally describe + // a live device_t, which keeps only the finished hash. + struct device_info_ + { + device_t::type_t_ type; + uint64_t unique_hash; + // Current size: BLKGETSIZE64 for a block device, st_size for a file. + file_offset_t size; + std::optional pool_metadata; + // The device number compute_unique_hash_ was given, so that the hash + // can be recomputed at a different size -- which is what validating a + // grown device's previous size needs. + uint64_t hash_dev_no; + }; + + // Everything about `source`, including the pool metadata read back from + // its end. + static device_info_ read_device_info_(std::filesystem::path const &source); + + // The hash formulae, each in one place so the validating pre-pass and + // adopt_device_ cannot drift apart. Members rather than file-local statics + // because device_t::type_t_ is private to device_t and only storage_pool + // is its friend. + static uint64_t compute_unique_hash_( + device_t::type_t_ type, uint64_t dev_no, file_offset_t size); + + static uint32_t compute_config_hash_(device_info_ const &); + + // A source which grew in place: it presents no footer at the end its + // current size gives it, because extending strands the footer mid-device, + // but carries a stranded one below which validates against the pool's own + // hash. + struct grown_device_ + { + // The recorded size, once validated: the size this device had when it + // was last part of this pool, as validated against the + // stranded footer's own config_hash. + file_offset_t previous_size; + size_t previous_chunks; // chunks() at previous_size + uint32_t chunk_capacity; // read back from the stranded footer + uint32_t num_cnv_chunks; + }; + + static device_info_ device_info_of_(device_t const &); + + // The device as it was before it grew, which is what the pool's pre-grow + // config_hash covers. + static device_info_ device_info_at_previous_size_( + device_info_ const &now, grown_device_ const &grown); + + // Reads the sizeof(metadata_t) bytes a footer would occupy if the device + // were exactly `size` bytes long, and returns it if it carries the magic. + static std::optional + read_footer_for_size_(int fd, file_offset_t size); + + // Checks `recorded_size` against the footer stranded there by the extend + // which grew `source`, for a source presenting no footer at its end. + // Returns nothing unless that footer describes the pool as it was at + // `recorded_size`; `footer_found` then distinguishes a size with no footer + // at all from one whose footer belongs elsewhere. Reads only. + static std::optional validate_grown_device_( + std::filesystem::path const &source, device_info_ const ¤t, + std::optional recorded_size, bool &footer_found); + + // Aborts, having written nothing, if `info` is not a device this pool can + // take up: not blank and carrying no footer that `recorded_size` explains, + // or grown past what the chunk id space or `budget` allows. + static std::optional validate_device_to_rescan_( + std::filesystem::path const &source, device_info_ const &info, + std::optional recorded_size, + std::optional const &budget); + + // Writes the grown device's metadata region at the end its current size + // gives it: the bytes-used array carried over from the region stranded at + // `grown.previous_size` with the new chunks zeroed, then the footer. The + // footer is written and made durable last, so it is the commit record -- + // and with one device it is the only one, so nothing else has to be + // durable first. + static void relocate_device_metadata_( + std::filesystem::path const &source, file_offset_t current_size, + grown_device_ const &grown, uint32_t new_config_hash); + static device_t make_device_( mode op, device_t::type_t_ type, std::filesystem::path const &path, int fd, std::variant dev_no_or_dev, @@ -427,12 +600,44 @@ class storage_pool return is_newly_truncated_; } + //! \brief True if the storage pool was opened with mode::rescan. + //! Consulted by DbMetadataContext to decide whether a pool reporting more + //! chunks than the metadata describes should be grown or rejected with a + //! "run monad-mpt --rescan-devices" message. + bool is_rescanning() const noexcept + { + return is_rescanning_; + } + //! \brief Returns the backing storage device device_t const &device() const noexcept { return device_; } + //! \brief What a mode::rescan open of `source` would do, decided without + //! writing anything. + struct rescan_preview + { + //! The validated previous size of the source: the size it had before + //! it was extended in place, zero if it was not. Its contents are kept + //! either way. + file_offset_t grown_previous_size; + //! Total chunks, cnv and seq, the source offered at that size. + size_t grown_previous_chunks; + }; + + //! \brief Classifies `source` as a mode::rescan open would, writing + //! nothing. It applies the same refusals, so a caller can put an accurate + //! confirmation prompt in front of the operation and know the operation + //! will not then refuse it. `recorded_size` is what db_metadata holds for + //! the source; the validated previous size it yields comes back in + //! `grown_previous_size`. + static rescan_preview preview_rescan( + std::filesystem::path const &source, + std::optional recorded_size, + std::optional const &budget); + //! \brief Returns the number of chunks for the specified type size_t chunks(chunk_type const which) const noexcept { diff --git a/category/async/test/storage_pool.cpp b/category/async/test/storage_pool.cpp index d24924dbf1..b3a87f1765 100644 --- a/category/async/test/storage_pool.cpp +++ b/category/async/test/storage_pool.cpp @@ -19,19 +19,24 @@ #include #include #include +#include #include #include +#include #include // NOLINT #include +#include #include #include #include #include #include +#include #include #include +#include #include #include @@ -234,6 +239,8 @@ namespace TEST(StoragePool, raw_partitions) { + // open_device_ is the first thing to touch the source, so a path that + // cannot be opened aborts before any device has been modified. ASSERT_DEATH( ({ storage_pool const pool( @@ -279,6 +286,15 @@ namespace } std::filesystem::copy_file( dev, copy, std::filesystem::copy_options::overwrite_existing); + // A rescan sees the same foreign footer, and preview_rescan is what + // puts a confirmation prompt in front of one: refusing only at the + // open below would mean prompting for an operation that then refuses. + ASSERT_DEATH( + storage_pool::preview_rescan( + copy, + std::nullopt, + storage_pool::db_metadata_budget::no_database()), + "initialised with a configuration different to this storage pool"); ASSERT_DEATH( (storage_pool{copy, storage_pool::mode::open_existing, flags}), "was initialised with a configuration different to this storage " @@ -286,6 +302,300 @@ namespace storage_pool{copy, storage_pool::mode::truncate, flags}; } + TEST(StoragePool, config_hash_formula_is_pinned) + { + using monad::async::test::StoragePoolConfigHashInput; + using monad::async::test::StoragePoolTestAccess; + + // Fixed, hardcoded inputs -- not read from a real device, whose + // unique_hash varies by inode and filesystem -- so this test isolates + // compute_config_hash_ itself. This value pins the on-disk format: + // changing it means every existing pool becomes unopenable. + StoragePoolConfigHashInput const device{ + 0x1122334455667788ULL, 4091, 1u << 28}; + EXPECT_EQ( + StoragePoolTestAccess::compute_config_hash(device), 0xcc1041d7u); + } + + TEST(StoragePool, rescan_refuses_a_device_the_metadata_cannot_describe) + { + using monad::async::test::StoragePoolRescanInput; + using monad::async::test::StoragePoolTestAccess; + + // A budget the size of MONAD008's, which the pool only does + // arithmetic with: a 2Mb chunk capacity leaves 1Mb of database + // metadata, which this header and 8 bytes per chunk exhaust at about + // 65000 chunks. + static constexpr storage_pool::db_metadata_budget budget{ + .header_bytes = 528512, .bytes_per_chunk = 8}; + static constexpr file_offset_t TWO_MB = 2 * 1024 * 1024; + ASSERT_DEATH( + StoragePoolTestAccess::validate_device_to_rescan( + {.size = 70000 * TWO_MB, + .chunk_capacity = uint32_t(TWO_MB), + .num_cnv_chunks = 3}, + budget), + "chunk capacity is too small"); + + // At the 256Mb default the metadata budget is ample, so the 20 bit + // chunk id space binds first. + static constexpr file_offset_t CHUNK = 256 * 1024 * 1024; + ASSERT_DEATH( + StoragePoolTestAccess::validate_device_to_rescan( + {.size = 1100000 * CHUNK, + .chunk_capacity = uint32_t(CHUNK), + .num_cnv_chunks = 3}, + budget), + "20 bit chunk id space"); + } + + // Whether the device carries a pool footer at its end. The tests use this + // to confirm a crash window was actually built before exercising resume. + bool device_has_footer(std::filesystem::path const &source) + { + int const fd = ::open(source.c_str(), O_RDONLY | O_CLOEXEC); + MONAD_ASSERT(fd != -1); + auto const unfd = + monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + auto const size = + static_cast(std::filesystem::file_size(source)); + std::array magic{}; + return ::pread(fd, magic.data(), magic.size(), size - 4) == 4 && + memcmp(magic.data(), "MND0", 4) == 0; + } + + // Fixture for the grow tests: builds a pool, writes a known amount into + // one seq chunk so the device is not blank, and can then extend it in + // place. + struct growable_pool + { + static constexpr file_offset_t BLKSIZE = 256 * 1024 * 1024; + static constexpr uint32_t MARKED_BYTES = 40960; + + std::filesystem::path dev; + uint32_t marked_chunk{0}; + size_t chunks_before{0}; + // What db_metadata recorded for the device, i.e. its size as of the + // pool open the constructor performed. + file_offset_t recorded_size{0}; + + explicit growable_pool(file_offset_t const length) + { + monad::test::remove_stale_temp_files_once( + working_temporary_directory(), "monad_storage_pool_test_"); + dev = working_temporary_directory() / + "monad_storage_pool_test_XXXXXX"; + int const fd = ::mkstemp((char *)dev.native().data()); + MONAD_ASSERT(fd != -1); + MONAD_ASSERT( + -1 != ::ftruncate(fd, static_cast(length + 16384))); + ::close(fd); + + storage_pool pool{dev}; + chunks_before = pool.chunks(storage_pool::seq); + marked_chunk = static_cast(chunks_before - 1); + // Chunk 0 is written too: a live pool always carries db_metadata, + // so a device reading as entirely blank is not a state worth + // modelling. + for (uint32_t const id : {0u, marked_chunk}) { + std::vector buffer(MARKED_BYTES, std::byte{0xa5}); + auto chunk = pool.chunk(storage_pool::seq, id); + auto const wfd = chunk.write_fd(MARKED_BYTES); + MONAD_ASSERT( + ssize_t(MARKED_BYTES) == + ::pwrite( + wfd.first, + buffer.data(), + MARKED_BYTES, + static_cast(wfd.second))); + } + recorded_size = size(); + } + + growable_pool(growable_pool const &) = delete; + growable_pool &operator=(growable_pool const &) = delete; + + ~growable_pool() + { + std::filesystem::remove(dev); + } + + void extend_to(file_offset_t const to) + { + int const fd = ::open(dev.c_str(), O_RDWR); + MONAD_ASSERT(fd != -1); + auto const unfd = + monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + MONAD_ASSERT(-1 != ::ftruncate(fd, static_cast(to))); + } + + file_offset_t size() const + { + return static_cast(std::filesystem::file_size(dev)); + } + + // What monad-mpt hands the pool: only the recorded size can locate the + // metadata an extend stranded, so a grow is refused without it. + storage_pool::creation_flags recorded_flags() const + { + storage_pool::creation_flags flags; + flags.recorded_size_of_grown_device = recorded_size; + flags.metadata_budget = + storage_pool::db_metadata_budget::no_database(); + return flags; + } + }; + + // The motivating case: one logical volume, extended in place. There is no + // sibling to cross-check the recorded previous size against, so it is + // validated against the stranded footer's own config_hash. + TEST(StoragePool, grow_single_device_pool) + { + growable_pool fixture{10 * growable_pool::BLKSIZE}; + fixture.extend_to(14 * growable_pool::BLKSIZE + 16384); + + storage_pool pool{ + fixture.dev, storage_pool::mode::rescan, fixture.recorded_flags()}; + EXPECT_GT(pool.chunks(storage_pool::seq), fixture.chunks_before); + EXPECT_FALSE(pool.device().is_freshly_initialised()); + EXPECT_EQ( + pool.chunk(storage_pool::seq, fixture.marked_chunk).size(), + growable_pool::MARKED_BYTES); + } + + // The footer at the new end is the relocation's commit record, so a crash + // before it is durable must leave the device re-runnable with its + // bytes-used accounting intact. Reproduced by clearing that footer's + // magic after a completed relocation: the new array is in place, the + // commit is not, which is exactly the window. The recorded size is still + // the pre-grow one there, since a crash this early is a crash before the + // metadata layer ran at all. + TEST(StoragePool, grow_interrupted_before_the_footer_reruns) + { + growable_pool fixture{10 * growable_pool::BLKSIZE}; + fixture.extend_to(14 * growable_pool::BLKSIZE + 16384); + { + storage_pool pool{ + fixture.dev, + storage_pool::mode::rescan, + fixture.recorded_flags()}; + ASSERT_EQ( + pool.chunk(storage_pool::seq, fixture.marked_chunk).size(), + growable_pool::MARKED_BYTES); + } + + auto const size = fixture.size(); + { + int const fd = ::open(fixture.dev.c_str(), O_RDWR); + ASSERT_NE(fd, -1); + auto const unfd = + monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + std::array const cleared{}; + ASSERT_EQ( + ssize_t(cleared.size()), + ::pwrite( + fd, + cleared.data(), + cleared.size(), + static_cast(size - cleared.size()))); + ASSERT_EQ(0, ::fsync(fd)); + } + ASSERT_FALSE(device_has_footer(fixture.dev)) + << "the crash window was not built"; + + // Re-running redoes the whole operation from the stranded footer, + // which this never touched. + storage_pool pool{ + fixture.dev, storage_pool::mode::rescan, fixture.recorded_flags()}; + EXPECT_GT(pool.chunks(storage_pool::seq), fixture.chunks_before); + EXPECT_EQ( + pool.chunk(storage_pool::seq, fixture.marked_chunk).size(), + growable_pool::MARKED_BYTES); + } + + // A device can be extended again after a completed grow, and what the + // second run must be given is the size the first one left it at. + TEST(StoragePool, grow_twice_in_succession) + { + growable_pool fixture{10 * growable_pool::BLKSIZE}; + fixture.extend_to(12 * growable_pool::BLKSIZE + 16384); + size_t after_first = 0; + { + storage_pool const pool{ + fixture.dev, + storage_pool::mode::rescan, + fixture.recorded_flags()}; + after_first = pool.chunks(storage_pool::seq); + } + ASSERT_GT(after_first, fixture.chunks_before); + + // That run was a writable open, so this is what the database now + // records for the device. + auto const recorded = fixture.size(); + fixture.extend_to(15 * growable_pool::BLKSIZE + 16384); + auto flags = fixture.recorded_flags(); + flags.recorded_size_of_grown_device = recorded; + storage_pool pool{fixture.dev, storage_pool::mode::rescan, flags}; + EXPECT_GT(pool.chunks(storage_pool::seq), after_first); + EXPECT_EQ( + pool.chunk(storage_pool::seq, fixture.marked_chunk).size(), + growable_pool::MARKED_BYTES); + } + + // The new metadata region must clear the old one, or writing it would + // destroy the bytes-used array before the new footer is durable. + TEST(StoragePool, grow_too_small_to_clear_the_old_metadata_is_refused) + { + growable_pool fixture{10 * growable_pool::BLKSIZE}; + // The region is only 64 bytes plus four per chunk, so this refusal + // takes a growth far below anything an operator would ask for; it + // exists to keep the crash window closed, not to reject real input. + auto const before = fixture.size(); + fixture.extend_to(before + 64); + + ASSERT_DEATH( + storage_pool( + fixture.dev, + storage_pool::mode::rescan, + fixture.recorded_flags()), + "would overwrite the metadata being recovered"); + // Refused before anything was written: the stranded footer is still + // the only one on the device. + EXPECT_FALSE(device_has_footer(fixture.dev)); + } + + // The recorded size is checked against the pool's own hash before it is + // acted on, so a wrong one is refused rather than used. Four bytes + // spelling MND0 turn up in trie data eventually, and this is what stops + // one of them being taken for a footer. + TEST(StoragePool, grow_with_a_wrong_recorded_size_is_refused) + { + growable_pool fixture{10 * growable_pool::BLKSIZE}; + fixture.extend_to(14 * growable_pool::BLKSIZE + 16384); + + auto flags = fixture.recorded_flags(); + flags.recorded_size_of_grown_device = fixture.recorded_size - 8192; + ASSERT_DEATH( + storage_pool(fixture.dev, storage_pool::mode::rescan, flags), + "nor any at the"); + EXPECT_FALSE(device_has_footer(fixture.dev)); + } + + // Without the recorded size nothing can say where the extend left the + // metadata, and the refusal has to say how to get one. + TEST(StoragePool, grow_without_a_recorded_size_is_refused) + { + growable_pool fixture{10 * growable_pool::BLKSIZE}; + fixture.extend_to(14 * growable_pool::BLKSIZE + 16384); + + storage_pool::creation_flags flags; + flags.metadata_budget = storage_pool::db_metadata_budget::no_database(); + ASSERT_DEATH( + storage_pool(fixture.dev, storage_pool::mode::rescan, flags), + "must be opened writable once before its device is extended"); + EXPECT_FALSE(device_has_footer(fixture.dev)); + } + TEST(StoragePool, clone_content) { storage_pool pool1(use_anonymous_inode_tag{}); diff --git a/category/async/test/storage_pool_test_access.hpp b/category/async/test/storage_pool_test_access.hpp new file mode 100644 index 0000000000..dea9f83c51 --- /dev/null +++ b/category/async/test/storage_pool_test_access.hpp @@ -0,0 +1,86 @@ +// Copyright (C) 2025-26 Category Labs, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +#include +#include + +#include +#include +#include + +MONAD_ASYNC_NAMESPACE_BEGIN + +namespace test +{ + // The device_info_ fields compute_config_hash_ reads. A standalone + // type rather than storage_pool::device_info_ itself, so this header + // does not also need friend access to device_t::type_t_. + struct StoragePoolConfigHashInput + { + uint64_t unique_hash; + size_t chunks; + uint32_t chunk_capacity; + }; + + // The device_info_ fields validate_device_to_rescan_ reads. Lets a unit + // test reach refusals whose real trigger is a device far larger than any + // test machine can provision. + struct StoragePoolRescanInput + { + MONAD_ASYNC_NAMESPACE::file_offset_t size; + uint32_t chunk_capacity; + uint32_t num_cnv_chunks; + }; + + // Test-only access to storage_pool's private on-disk hash formula, so a + // unit test can pin a golden value against the real implementation + // without a live device_t. + struct StoragePoolTestAccess + { + static void validate_device_to_rescan( + StoragePoolRescanInput const &device, + std::optional const &budget) + { + storage_pool::device_info_ info{}; + info.size = device.size; + info.pool_metadata = storage_pool::device_pool_metadata_{ + .chunk_capacity = device.chunk_capacity, + .num_cnv_chunks = device.num_cnv_chunks, + // Never adopted, so the footer's identity check passes over it + // and the size refusals this reaches for are the ones that + // fire. A synthetic device has no hash worth computing. + .config_hash = 0, + .chunks = 0}; + (void)storage_pool::validate_device_to_rescan_( + "device", info, std::nullopt, budget); + } + + static uint32_t compute_config_hash(StoragePoolConfigHashInput const &d) + { + storage_pool::device_info_ info{}; + info.unique_hash = d.unique_hash; + info.pool_metadata = storage_pool::device_pool_metadata_{ + .chunk_capacity = d.chunk_capacity, + .num_cnv_chunks = 0, + .config_hash = 0, + .chunks = d.chunks}; + return storage_pool::compute_config_hash_(info); + } + }; +} + +MONAD_ASYNC_NAMESPACE_END diff --git a/category/mpt/cli_tool_impl.cpp b/category/mpt/cli_tool_impl.cpp index d7db5e84a6..ff7d3ca563 100644 --- a/category/mpt/cli_tool_impl.cpp +++ b/category/mpt/cli_tool_impl.cpp @@ -39,6 +39,7 @@ #include #include +#include #include #include #include @@ -79,6 +80,52 @@ #include #include +// What the database on a device says about itself. +struct device_database +{ + // The size the device reported at the last writable open, which is no + // longer its current size if it has since been extended. Nothing if none + // was recorded, i.e. a database written before the field existed. + std::optional recorded_size; +}; + +// Nothing if the device carries no database at all. +// +// db_metadata's first copy is read straight off the device without opening +// the pool, which is the only way round the circularity: the device whose +// previous size is being recovered has no footer at its end until this has +// answered. +// +// Only that copy is read, so a corrupt one reads as no database even when the +// second still holds one. The second sits at half the conventional chunk +// capacity, which only the footer states, and that footer is the very thing +// the extend stranded. Nor is there a pool open to heal one copy from the +// other, since a pool with an extended device cannot be opened at all. +std::optional +read_device_database(std::filesystem::path const &device) +{ + using MONAD_MPT_NAMESPACE::detail::db_metadata; + int const fd = ::open(device.c_str(), O_RDONLY | O_CLOEXEC); + if (fd == -1) { + return std::nullopt; + } + auto const unfd = monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + alignas(db_metadata) std::array buffer{}; + if (::pread(fd, buffer.data(), buffer.size(), 0) != + ssize_t(buffer.size())) { + return std::nullopt; + } + auto const *const m = monad::start_lifetime_as(buffer.data()); + if (0 != + memcmp(m->magic, db_metadata::MAGIC, db_metadata::MAGIC_STRING_LEN)) { + return std::nullopt; + } + if (m->recorded_device_size == 0) { + return device_database{}; + } + return device_database{.recorded_size = m->recorded_device_size}; +} + std::string print_bytes(MONAD_ASYNC_NAMESPACE::file_offset_t const bytes_) { auto bytes = double(bytes_); @@ -432,6 +479,10 @@ struct impl_t std::filesystem::path archive_database; std::filesystem::path restore_database; std::filesystem::path storage_path; + bool rescan_devices = false; + // What --rescan-devices classified the storage as, kept so the summary + // after the pool is open can report how much the device grew by. + MONAD_ASYNC_NAMESPACE::storage_pool::rescan_preview rescan{}; int compression_level = 3; std::optional pool; @@ -1499,7 +1550,9 @@ int main_impl( set it to the desired size beforehand). The storage source must be the same device the database was created on, of -the same type, size and device id, otherwise the database cannot be opened. +the same type and device id, otherwise the database cannot be opened. An +existing database can take up more storage with --rescan-devices, after that +device has been extended in place. )"); try { impl_t impl(cout, cerr); @@ -1569,6 +1622,19 @@ the same type, size and device id, otherwise the database cannot be opened. "sentinel, which would make a later --activate-secondary wipe " "the metadata. Normalises it so activation is safe. Run with " "the daemon stopped."); + cli_ops_group->add_flag( + "--rescan-devices", + impl.rescan_devices, + "reconcile an existing database with the storage it is now " + "given: take up the space its device gained from being " + "extended in place. --storage must name the database's own " + "device, which keeps its contents. Taking up an extended " + "device needs the size the database recorded for it, which " + "every writable open writes, so only extend a device of a " + "database this release has already opened. Run with the daemon " + "stopped. An interrupted run is finished by re-running the " + "identical command, which resumes it from wherever it " + "stopped."); cli_ops_group->add_option( "--reset-history-length", impl.reset_history_length, @@ -1679,6 +1745,27 @@ the same type, size and device id, otherwise the database cannot be opened. impl.flags.num_cnv_chunks = impl.root_offsets_chunk_count + monad::mpt::UpdateAux::cnv_chunks_for_db_metadata; + // What db_metadata costs, so the pool can refuse a device set the + // database could not describe before it writes any footer. + impl.flags.metadata_budget = + MONAD_ASYNC_NAMESPACE::storage_pool::db_metadata_budget{ + .header_bytes = + monad::mpt::detail::db_metadata::MONAD007_HEADER_BYTES, + .bytes_per_chunk = + sizeof(monad::mpt::detail::db_metadata::chunk_info_t)}; + // --restore sets truncate_database below, so this must run first: + // otherwise --rescan-devices --restore would fall into the truncate + // branch further down and destroy the pool before this guard is + // ever reached. + bool const restore_or_archive_requested = + !impl.restore_database.empty() || + !impl.archive_database.empty(); + if (impl.rescan_devices && restore_or_archive_requested) { + cerr << "FATAL: --rescan-devices cannot be combined with " + "--restore or --archive. Take up the storage first, " + "then run the archive or restore separately.\n"; + return 1; + } if (!impl.restore_database.empty()) { if (!impl.archive_database.empty()) { impl.cli_ask_question( @@ -1723,6 +1810,81 @@ the same type, size and device id, otherwise the database cannot be opened. impl.flags.open_read_only_allow_dirty = false; impl.flags.allow_migration = true; } + else if (impl.rescan_devices) { + // The pool constructor aborts on a path it cannot open, so a + // mistyped argument has to be caught here to be reported + // rather than dumped as a crash. + auto const &p = impl.storage_path; + std::error_code ec; + auto const status = std::filesystem::status(p, ec); + if (ec) { + cerr << "FATAL: cannot examine " << p << ": " + << ec.message() << "\n"; + return 1; + } + if (status.type() != std::filesystem::file_type::regular && + status.type() != std::filesystem::file_type::block) { + cerr << "FATAL: " << p + << " is neither a file nor a block device, so it " + "cannot be a source of block storage.\n"; + return 1; + } + if (-1 == ::access(p.c_str(), R_OK | W_OK)) { + cerr << "FATAL: " << p << " is not readable and " + << "writable: " << strerror(errno) << "\n"; + return 1; + } + // Classify before prompting, so every refusal is raised before + // the operator is asked to confirm anything. + // + // A rescan takes up storage for a database that is already + // there. Refuse an absent one here, before the pool is opened: + // UpdateAux's constructor initialises a database onto any pool + // that has none, which would both destroy the evidence that + // the wrong device was named and let an identical re-run + // report success on the empty database it just created. + auto const database = read_device_database(p); + if (!database.has_value()) { + cerr << "FATAL: " << p + << " holds no database, and --rescan-devices takes up " + "storage for one that is already there. Name the " + "device the database was last opened with.\n"; + return 1; + } + // The size db_metadata recorded for the device is the only + // thing which can locate the metadata an extend stranded. The + // pool checks it against its own hash, and refuses rather than + // guessing where it does not hold up. + auto const recorded_size = database->recorded_size; + auto const preview = + MONAD_ASYNC_NAMESPACE::storage_pool::preview_rescan( + p, recorded_size, impl.flags.metadata_budget); + std::stringstream ss; + ss << "WARNING: --rescan-devices"; + if (preview.grown_previous_size != 0) { + ss << " will relocate the pool metadata of " << p + << ", which was extended in place; its contents are " + "kept. This cannot be undone without a full " + "--archive and --restore. Are you sure?\n"; + } + else { + cout << "The pool already spans " << p + << " at its current size; a metadata growth an " + "earlier run left incomplete will be finished.\n"; + ss << " will destroy nothing; it will only finish work an " + "earlier run left incomplete. Are you sure?\n"; + } + impl.cli_ask_question(ss.str().c_str()); + mode = MONAD_ASYNC_NAMESPACE::storage_pool::mode::rescan; + impl.flags.open_read_only = false; + impl.flags.open_read_only_allow_dirty = false; + // The recorded size, not the preview's validated one: the + // open re-validates from scratch, and flattening "nothing + // grew" to zero here would re-engage the optional the pool + // uses to tell a missing record from a bad one. + impl.flags.recorded_size_of_grown_device = recorded_size; + impl.rescan = preview; + } else if ( impl.activate_secondary || impl.deactivate_secondary || impl.promote_secondary || impl.repair_database) { @@ -1747,7 +1909,8 @@ the same type, size and device id, otherwise the database cannot be opened. bool const needs_write_ring = impl.rewind_database_to || impl.reset_history_length || impl.activate_secondary || impl.deactivate_secondary || - impl.promote_secondary || impl.repair_database; + impl.promote_secondary || impl.repair_database || + impl.rescan_devices; auto wr_ring( needs_write_ring ? std::optional(monad::io::RingConfig{4}) @@ -1802,6 +1965,29 @@ the same type, size and device id, otherwise the database cannot be opened. } } + if (impl.rescan_devices) { + // Counted from the pool's own chunk count rather than from what + // this run relocated, so the totals stay right when the run was a + // resume, which relocates nothing. + cout << "Rescan complete.\n"; + if (impl.rescan.grown_previous_size != 0) { + cout << " " << impl.storage_path + << " was extended in place, from " + << impl.rescan.grown_previous_size << " bytes: " + << impl.pool->device().chunks() - + impl.rescan.grown_previous_chunks + << " more sequential chunks.\n"; + } + cout << " " + << impl.pool->chunks(MONAD_ASYNC_NAMESPACE::storage_pool::seq) + << " sequential chunks in total. Free space is now " + << aux.metadata_ctx().get_lower_bound_free_space() + << " bytes.\n"; + cout << " New chunks join the tail of the free list, so they are " + "allocated after all currently free chunks. Existing data " + "is not redistributed.\n"; + } + // Secondary timeline lifecycle. These execute against the open // UpdateAux; the daemon must be stopped beforehand (UpdateAux's // open holds the storage pool exclusively). On the next daemon diff --git a/category/mpt/db_metadata_context.cpp b/category/mpt/db_metadata_context.cpp index ebca660b8b..a19dd18d4e 100644 --- a/category/mpt/db_metadata_context.cpp +++ b/category/mpt/db_metadata_context.cpp @@ -58,12 +58,21 @@ using namespace MONAD_ASYNC_NAMESPACE; // root_offsets_ring_t::SIZE_ = 65536; MONAD008 dropped it to 32, shrinking // sizeof(db_metadata) from 528512 to 4480 bytes and shifting chunk_info[] // and the db_offsets/consensus block accordingly. -static constexpr size_t MONAD007_DB_METADATA_SIZE = 528512; +static constexpr size_t MONAD007_DB_METADATA_SIZE = + detail::db_metadata::MONAD007_HEADER_BYTES; static constexpr size_t MONAD007_DB_OFFSETS_OFFSET = 524328; static constexpr size_t MONAD007_DB_OFFSETS_THROUGH_BLOCK_IDS_BYTES = 128; static constexpr size_t MONAD007_LIST_TRIPLE_OFFSET = 528488; static constexpr size_t DB_METADATA_LIST_TRIPLE_BYTES = 24; +// storage_pool documents min_chunk_capacity as the smallest capacity a pool +// carrying a database can have; check_chunk_info_fits_ below is what enforces +// it at open time, by refusing a pool whose conventional chunk 0 cannot hold +// the header. Half the capacity is what that chunk offers the header. +static_assert( + storage_pool::min_chunk_capacity / 2 >= + detail::db_metadata::MONAD007_HEADER_BYTES); + namespace detail { void migrate_monad007_to_monad008( @@ -194,12 +203,7 @@ DbMetadataContext::DbMetadataContext(AsyncIO &io) metadata_mmap_size_ = cnv_chunk.capacity() / 2; db_map_size_ = sizeof(detail::db_metadata) + chunk_count * sizeof(detail::db_metadata::chunk_info_t); - MONAD_ASSERT( - metadata_mmap_size_ >= - MONAD007_DB_METADATA_SIZE + - chunk_count * sizeof(detail::db_metadata::chunk_info_t), - "cnv chunk 0 is too small to hold a MONAD007 metadata header plus " - "chunk_info[]; pool configuration is incompatible with this build"); + check_chunk_info_fits_(chunk_count, metadata_mmap_size_); // mmap both metadata copies copies_[0].main = start_lifetime_as(::mmap( @@ -238,7 +242,10 @@ DbMetadataContext::DbMetadataContext(AsyncIO &io) can_write_to_map_, "First copy of metadata corrupted, but not opened for " "healing"); - db_copy(copies_[0].main, copies_[1].main, db_map_size_); + db_copy( + copies_[0].main, + copies_[1].main, + db_map_size_of_(copies_[1].main)); } } @@ -355,11 +362,17 @@ DbMetadataContext::DbMetadataContext(AsyncIO &io) detail::db_metadata::MAGIC_STRING_LEN)) { if (can_write_to_map_) { if (copies_[0].main->is_dirty().load(std::memory_order_acquire)) { - db_copy(copies_[0].main, copies_[1].main, db_map_size_); + db_copy( + copies_[0].main, + copies_[1].main, + db_map_size_of_(copies_[1].main)); } else if (copies_[1].main->is_dirty().load( std::memory_order_acquire)) { - db_copy(copies_[1].main, copies_[0].main, db_map_size_); + db_copy( + copies_[1].main, + copies_[0].main, + db_map_size_of_(copies_[0].main)); } } else { @@ -457,6 +470,8 @@ DbMetadataContext::DbMetadataContext(AsyncIO &io) map_ring_a_storage(); map_ring_b_storage(); replay_pending_shrink_grow_(); + reconcile_chunk_count_(); + record_device_size_(); } } #if defined(__GNUC__) && !defined(__clang__) @@ -507,6 +522,123 @@ size_t DbMetadataContext::map_bytes_per_chunk_() const noexcept return io_->storage_pool().chunk(storage_pool::cnv, 0).capacity() / 2; } +void DbMetadataContext::check_chunk_info_fits_( + size_t const target, size_t const metadata_mmap_size) +{ + MONAD_ASSERT_PRINTF( + target <= MONAD_ASYNC_NAMESPACE::chunk_offset_t::max_id, + "The storage pool provides %zu sequential chunks, beyond the %llu the " + "20 bit chunk id space allows. The device is too large for this " + "pool's chunk capacity.", + target, + static_cast( + MONAD_ASYNC_NAMESPACE::chunk_offset_t::max_id)); + auto const needed = MONAD007_DB_METADATA_SIZE + + target * sizeof(detail::db_metadata::chunk_info_t); + MONAD_ASSERT_PRINTF( + metadata_mmap_size >= needed, + "The storage pool provides %zu sequential chunks, needing %zu bytes " + "of metadata, but conventional chunk 0 only provides %zu. This " + "pool's chunk capacity is too small to describe that many chunks.", + target, + needed, + metadata_mmap_size); +} + +size_t DbMetadataContext::db_map_size_of_( + detail::db_metadata const *const m) const noexcept +{ + auto const ret = + sizeof(detail::db_metadata) + + size_t(m->chunk_info_count) * sizeof(detail::db_metadata::chunk_info_t); + MONAD_ASSERT(ret <= metadata_mmap_size_); + return ret; +} + +void DbMetadataContext::grow_chunk_info_body_(uint32_t const target_chunk_count) +{ + for (auto const © : copies_) { + auto *const m = copy.main; + auto const from = static_cast(m->chunk_info_count); + if (from == target_chunk_count) { + continue; + } + MONAD_ASSERT(from < target_chunk_count); + uint64_t added = 0; + for (uint32_t n = from; n < target_chunk_count; n++) { + auto const chunk = io_->storage_pool().chunk(storage_pool::seq, n); + MONAD_ASSERT_PRINTF( + chunk.size() == 0, + "sequential chunk %u uncovered by the extend already holds " + "%zu bytes; refusing to take up storage which is not blank", + n, + size_t(chunk.size())); + added += chunk.capacity(); + } + m->extend_chunk_info_(target_chunk_count, added); + } +} + +void DbMetadataContext::record_device_size_() +{ + if (io_->storage_pool().is_read_only()) { + return; + } + auto const size = io_->storage_pool().device().size_bytes(); + for (auto const © : copies_) { + auto *const m = copy.main; + auto const g = m->hold_dirty(); + m->recorded_device_size = size; + } + // Synced like every other mutation here, and for a sharper reason: this is + // the only record of a device's pre-extend size, and losing it refuses the + // next extend outright. + sync_metadata_to_disk_(); +} + +void DbMetadataContext::reconcile_chunk_count_() +{ + auto const pool_chunks = io_->chunk_count(); + auto const described = + static_cast(copies_[0].main->chunk_info_count); + if (described == pool_chunks) { + if (io_->storage_pool().is_rescanning()) { + LOG_INFO( + "DB metadata already describes all {} sequential chunks; the " + "rescan was already completed by an earlier run.", + described); + } + return; + } + MONAD_ASSERT_PRINTF( + described < pool_chunks, + "DB metadata describes %zu sequential chunks but the storage pool " + "only provides %zu. A storage device appears to be missing.", + described, + pool_chunks); + MONAD_ASSERT_PRINTF( + io_->storage_pool().is_rescanning(), + "The storage pool provides %zu sequential chunks but the DB metadata " + "describes only %zu. If the device was extended in place, run " + "'monad-mpt --rescan-devices' with the " + "daemon stopped to take it up.", + pool_chunks, + described); + MONAD_ASSERT(can_write_to_map_); + auto const target = static_cast(pool_chunks); + LOG_INFO( + "Growing DB metadata from {} to {} sequential chunks after an " + "in-place device extend", + described, + target); + set_pending_shrink_grow_(detail::db_metadata::PENDING_OP_RESCAN, target); + sync_metadata_to_disk_(); + grow_chunk_info_body_(target); + sync_metadata_to_disk_(); + clear_pending_shrink_grow_(); + sync_metadata_to_disk_(); +} + // Version metadata getters uint64_t DbMetadataContext::get_latest_finalized_version() const noexcept @@ -1003,6 +1135,20 @@ void DbMetadataContext::replay_pending_shrink_grow_() op_param); do_promote_secondary_to_primary_body_(static_cast(op_param)); } + else if (op_kind == detail::db_metadata::PENDING_OP_RESCAN) { + MONAD_ASSERT_PRINTF( + op_param == io_->chunk_count(), + "An interrupted rescan targeted %u sequential chunks but the " + "storage pool provides %zu. Restore the device to the size it had " + "then and reopen to let the operation complete.", + op_param, + io_->chunk_count()); + LOG_INFO( + "Replaying in-flight chunk_info growth (target {} sequential " + "chunks) after unclean shutdown", + op_param); + grow_chunk_info_body_(op_param); + } else { MONAD_ABORT_PRINTF( "Unknown pending_shrink_grow op_kind %u in metadata; DB may be " @@ -1732,6 +1878,7 @@ void DbMetadataContext::init_new_pool( memset( m->future_variables_unused, 0, sizeof(m->future_variables_unused)); } + record_device_size_(); std::atomic_signal_fence( std::memory_order_seq_cst); // no compiler reordering here diff --git a/category/mpt/db_metadata_context.hpp b/category/mpt/db_metadata_context.hpp index 49c8abe8e0..29ef9ba228 100644 --- a/category/mpt/db_metadata_context.hpp +++ b/category/mpt/db_metadata_context.hpp @@ -53,6 +53,7 @@ static_assert(std::atomic_ref< class DbMetadataContext { friend class UpdateAux; + friend struct test::AddDevicesTestAccess; public: // Each metadata_copy describes one of the two redundant db_metadata @@ -600,6 +601,36 @@ class DbMetadataContext // operation to completion before the constructor returns. void replay_pending_shrink_grow_(); + // Inner body of the chunk_info[] growth which follows a rescan. + // Idempotent under replay: a copy already at `target` is left alone. + // Expects the pending flag to already be stamped. + void grow_chunk_info_body_(uint32_t target_chunk_count); + + // Called from the constructor after replay_pending_shrink_grow_. Compares + // chunk_info_count against the pool's seq chunk count and either does + // nothing, grows, or aborts naming monad-mpt --rescan-devices. + void reconcile_chunk_count_(); + + // Aborts if chunk_info[] cannot describe `target` chunks. Split out from + // the constructor so the limits can be unit tested without provisioning a + // multi-terabyte pool. + static void + check_chunk_info_fits_(size_t target, size_t metadata_mmap_size); + + // Stamps the device's current size into + // db_metadata::recorded_device_size, making that its recorded size, + // which monad-mpt later reads back as the previous size of a device grown + // in place. A no-op on a read-only open. On the open which performs a grow, + // the storage layer has already relocated the metadata by the time this + // runs, so the recorded size it advances past has served its purpose. + void record_device_size_(); + + // Logical metadata bytes described by `m` itself, as opposed to + // db_map_size_, which is derived from the pool. The two differ while a + // rescan is in flight, so healing must be sized from the clean source + // copy or it reads or writes past what that copy describes. + size_t db_map_size_of_(detail::db_metadata const *m) const noexcept; + MONAD_ASYNC_NAMESPACE::AsyncIO *io_{nullptr}; metadata_copy copies_[2]; // db_map_size_ is the logical bytes of live metadata (header + @@ -607,9 +638,10 @@ class DbMetadataContext // (cnv chunk 0 half-capacity). The latter is always >= the former // and >= MONAD007_DB_METADATA_SIZE + chunk_info[], so migration // from a MONAD007 pool can read and relocate chunk_info[] without - // remapping. Use metadata_mmap_size_ for mmap/munmap and - // db_map_size_ for msync/db_copy to avoid syncing megabytes of - // dead bytes beyond the logical metadata. + // remapping. Use metadata_mmap_size_ for mmap/munmap and db_map_size_ + // for msync, to avoid syncing megabytes of dead bytes beyond the logical + // metadata; a copy-to-copy db_copy is sized by db_map_size_of_ instead, + // as a copy can describe fewer chunks than the pool does mid-grow. size_t db_map_size_{0}; size_t metadata_mmap_size_{0}; bool is_new_pool_{false}; diff --git a/category/mpt/detail/db_metadata.hpp b/category/mpt/detail/db_metadata.hpp index b0722e00a0..6e72df63b5 100644 --- a/category/mpt/detail/db_metadata.hpp +++ b/category/mpt/detail/db_metadata.hpp @@ -27,6 +27,7 @@ #include "unsigned_20.hpp" #include +#include #include MONAD_MPT_NAMESPACE_BEGIN @@ -36,6 +37,7 @@ class UpdateAux; namespace test { struct DbMetadataTestAccess; // test-only access to ring internals + struct AddDevicesTestAccess; // test-only access to growth internals } namespace detail @@ -83,6 +85,14 @@ namespace detail // DbMetadataContext constructor). static constexpr char const *PREVIOUS_MAGIC = "MONAD007"; + // Fixed header of the MONAD007 layout, whose root_offsets_ring_t held + // 65536 slots. Still the largest of any format this code can read, so + // every pool must reserve it: migration relocates chunk_info[] in + // place rather than remapping. It is therefore also the header half of + // the budget storage_pool checks a device set against, which is why it + // is public rather than local to db_metadata_context.cpp. + static constexpr size_t MONAD007_HEADER_BYTES = 528512; + friend class MONAD_MPT_NAMESPACE::DbMetadataContext; friend class MONAD_MPT_NAMESPACE::UpdateAux; friend inline void @@ -258,24 +268,45 @@ namespace detail PENDING_OP_ACTIVATE = 1, // activate_secondary_header PENDING_OP_DEACTIVATE = 2, // deactivate_secondary_header PENDING_OP_PROMOTE = 3, // promote_secondary_to_primary_header + PENDING_OP_RESCAN = 4 // chunk_info[] growth after a rescan }; + // Frozen by the on-disk format: an interrupted operation records its + // kind here, so renumbering would replay it as a different one. + static_assert(PENDING_OP_NONE == 0); + static_assert(PENDING_OP_ACTIVATE == 1); + static_assert(PENDING_OP_DEACTIVATE == 2); + static_assert(PENDING_OP_PROMOTE == 3); + static_assert(PENDING_OP_RESCAN == 4); + struct pending_shrink_grow_t { uint32_t op_kind; // pending_op_kind // op-specific param: target primary cnv_chunks_len for - // ACTIVATE/DEACTIVATE; target primary_ring_idx for PROMOTE. + // ACTIVATE/DEACTIVATE; target primary_ring_idx for PROMOTE; + // target chunk_info_count for RESCAN. uint32_t op_param; } pending_shrink_grow; static_assert(sizeof(pending_shrink_grow_t) == 8); + // The *recorded* size in bytes of the pool's device: what it reported + // at the last writable open, which is no longer its current size once + // it has been extended in place. Zero means not recorded, i.e. a pool + // predating this field. An extend strands the device's footer + // mid-device, so the device stops reporting its own former geometry + // and this becomes the only record of it; monad-mpt --rescan-devices + // refuses to take up an extended device without it. Carved from the + // padding below, which both a fresh pool and the MONAD007 migration + // zero, so the sentinel holds on every pool that already exists. + uint64_t recorded_device_size; + // padding for adding future atomics without requiring DB reset. // Sized so sizeof(db_metadata) stays at 4480 regardless of how // timeline_state_t grows in subsequent PRs. uint8_t future_variables_unused [4040 - sizeof(root_offsets_ring_t) - 2 * sizeof(timeline_state_t) - - 16 - sizeof(pending_shrink_grow_t)]; + 16 - sizeof(pending_shrink_grow_t) - sizeof(uint64_t)]; // used to know if the metadata was being // updated when the process suddenly exited @@ -305,7 +336,14 @@ namespace detail struct chunk_info_t { - static constexpr uint32_t INVALID_CHUNK_ID = 0xfffff; + // The links below carry chunk_offset_t's own chunk id, whose top + // value can never be a real chunk id, so it marks an absent link. + static constexpr uint32_t INVALID_CHUNK_ID = static_cast( + MONAD_ASYNC_NAMESPACE::chunk_offset_t::max_id); + // Frozen by the on-disk format: every existing pool's free list + // terminates on this value, so widening chunk_offset_t's id field + // would reinterpret those terminators rather than migrate them. + static_assert(INVALID_CHUNK_ID == 0xfffff); uint64_t prev_chunk_id : 20; // same bits as from chunk_offset_t uint64_t in_fast_list : 1; uint64_t in_slow_list : 1; @@ -586,6 +624,56 @@ namespace detail capacity_in_free_list -= bytes; } + // Grow chunk_info[] to `to` entries, append every new entry to the + // free list continuing insertion counts from the current tail, and + // fold in the new chunks' capacity. All of it in one dirty scope, so + // a crash part-way through is caught by dirty-bit recovery rather + // than leaving a half-linked list or an understated capacity. + void extend_chunk_info_( + uint32_t const to, uint64_t const added_capacity_bytes) noexcept + { + auto const g = hold_dirty(); + auto const from = uint32_t(chunk_info_count); + MONAD_ASSERT(to >= from); + MONAD_ASSERT((to & ~0xfffffU) == 0); + chunk_info_count = to & 0xfffffU; + for (uint32_t n = from; n < to; n++) { + chunk_info_t info; + info.in_fast_list = 0; + info.in_slow_list = 0; + info.unused0_ = 0; + info.next_chunk_id = chunk_info_t::INVALID_CHUNK_ID; + if (free_list.end == NULL_CHUNK) { + MONAD_ASSERT(free_list.begin == NULL_CHUNK); + info.prev_chunk_id = chunk_info_t::INVALID_CHUNK_ID; + info.insertion_count0_ = 0; + info.insertion_count1_ = 0; + free_list.begin = free_list.end = n; + } + else { + MONAD_ASSERT((free_list.end & ~0xfffffU) == 0); + auto *const tail = at_(free_list.end); + uint32_t const insertion_count = + uint32_t(tail->insertion_count()) + 1; + MONAD_ASSERT( + insertion_count < virtual_chunk_offset_t::MAX_COUNT, + "Chunk count overflow detected. The 20-bit address " + "space for chunk count has been exhausted. Please " + "perform a database reset."); + info.insertion_count0_ = insertion_count & 0x3ff; + info.insertion_count1_ = insertion_count >> 10 & 0x3ff; + info.prev_chunk_id = free_list.end & 0xfffffU; + MONAD_ASSERT( + tail->next_chunk_id == chunk_info_t::INVALID_CHUNK_ID); + tail->next_chunk_id = n & 0xfffffU; + free_list.end = n; + } + std::atomic_ref(chunk_info[n]) + .store(info, std::memory_order_release); + } + capacity_in_free_list += added_capacity_bytes; + } + void advance_db_offsets_to_( db_offsets_info_t const &offsets_to_apply) noexcept { @@ -604,6 +692,14 @@ namespace detail // chunk_info[] budget for any given chunk_count. static_assert(sizeof(db_metadata) == 4480); static_assert(alignof(db_metadata) == 8); + // recorded_device_size reads zero on a pool that predates it only + // while it stays inside the window the MONAD007 migration zeroes. + static_assert( + offsetof(db_metadata, recorded_device_size) > + offsetof(db_metadata, secondary_timeline)); + static_assert( + offsetof(db_metadata, recorded_device_size) + sizeof(uint64_t) <= + offsetof(db_metadata, free_list)); inline void atomic_memcpy( void *__restrict__ const dest_, void const *__restrict__ const src_, diff --git a/category/mpt/test/CMakeLists.txt b/category/mpt/test/CMakeLists.txt index fccba6fd35..57310f06f3 100644 --- a/category/mpt/test/CMakeLists.txt +++ b/category/mpt/test/CMakeLists.txt @@ -13,6 +13,15 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +monad_add_test_death( + rescan_death_no_mode_test + SOURCES + "rescan_death_no_mode.cpp" + LINK_LIBRARIES + monad_execution + FAIL_REGEX + ".*run 'monad-mpt --rescan-devices.*") +add_trie_test(TARGET rescan_devices_test SOURCES "rescan_devices_test.cpp") add_trie_test(TARGET append_test SOURCES "append_test.cpp") add_trie_test(TARGET big_endian_serialization_test SOURCES "big_endian_serialization_test.cpp") diff --git a/category/mpt/test/cli_tool_test.cpp b/category/mpt/test/cli_tool_test.cpp index d087c9aaf6..429ebc02b1 100644 --- a/category/mpt/test/cli_tool_test.cpp +++ b/category/mpt/test/cli_tool_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -41,7 +42,10 @@ #include #include +#include #include +#include +#include #include #include #include @@ -56,6 +60,7 @@ #include #include +#include #include #include @@ -277,6 +282,46 @@ namespace ::testing::Environment *const sweep_stale_temp_pools = ::testing::AddGlobalTestEnvironment(new SweepStaleTempPools); + // Whether a database has been stamped on the device, read raw so that + // asking the question does not itself create one. + bool device_carries_db_metadata(char const *const path) + { + using monad::mpt::detail::db_metadata; + int const fd = ::open(path, O_RDONLY | O_CLOEXEC); + MONAD_ASSERT(fd != -1); + auto const unfd = + monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + std::array magic{}; + return ::pread(fd, magic.data(), magic.size(), 0) == + ssize_t(magic.size()) && + 0 == memcmp( + magic.data(), + db_metadata::MAGIC, + db_metadata::MAGIC_STRING_LEN); + } + + // A death test's child aborts by design, so its scope guards never run and + // the pool file it built -- tens of megabytes of real blocks -- would be + // left to the age-based sweep above. The handler runs in the aborting + // process, so the path has to sit in a fixed buffer. + char abort_unlink_path[64]; + + extern "C" void unlink_fixture_then_die(int const sig) + { + if (abort_unlink_path[0] != '\0') { + (void)::unlink(abort_unlink_path); + } + (void)::signal(sig, SIG_DFL); + (void)::raise(sig); + } + + void unlink_on_abort(char const *const path) + { + MONAD_ASSERT(std::strlen(path) < sizeof(abort_unlink_path)); + std::strncpy(abort_unlink_path, path, sizeof(abort_unlink_path) - 1); + (void)::signal(SIGABRT, unlink_fixture_then_die); + } + // Tests here pass --root-offsets-chunk-count 2 rather than the CLI's // production default of 16, which cuts what --create writes by 8x. Two is // the minimum activate_secondary_header accepts, so secondary-lifecycle @@ -2169,3 +2214,357 @@ TEST(cli_tool, upgrade_requires_storage) ASSERT_NE(0, retcode); EXPECT_TRUE(cerr.str().starts_with("FATAL:")); } + +// The whole feature through the tool, on exactly the state lvextend leaves: +// a database written, closed, and its only device made larger. +TEST(cli_tool, rescan_devices_takes_up_an_extended_device) +{ + char path0[] = "cli_tool_tmp_ext0_XXXXXX"; + make_temp_pool(path0); + auto const untempfile = + monad::make_scope_exit([&]() noexcept { unlink(path0); }); + + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + path0, + "--create", + "--root-offsets-chunk-count", + "2", + "--chunk-capacity", + "24"}; + ASSERT_EQ(0, main_impl(cout, cerr, args)); + } + + monad::byte_string const key = + monad::byte_string(32, static_cast(0x3c)); + monad::byte_string const value = + monad::byte_string(64, static_cast(0xc3)); + { + monad::mpt::OnDiskDbConfig const config{ + .dbname_path = path0, + .fixed_history_length = MPT_TEST_HISTORY_LENGTH, + .chunk_capacity = 24}; + monad::mpt::Db db{std::make_unique(), config}; + upsert_one(db, key, value, nullptr); + } + + uint64_t free_before = 0; + size_t chunks_before = 0; + { + MONAD_ASYNC_NAMESPACE::storage_pool pool{path0}; + chunks_before = pool.chunks(MONAD_ASYNC_NAMESPACE::storage_pool::seq); + monad::io::Ring ring; + auto buffers = monad::io::make_buffers_for_read_only( + ring, + 1, + MONAD_ASYNC_NAMESPACE::AsyncIO::MONAD_IO_BUFFERS_READ_SIZE); + MONAD_ASYNC_NAMESPACE::AsyncIO io{pool, buffers}; + monad::mpt::UpdateAux const aux{io}; + free_before = aux.metadata_ctx().get_lower_bound_free_space(); + } + + ASSERT_EQ(0, ::truncate(path0, 10ULL * 1024 * 1024 * 1024)); + + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", "--storage", path0, "--rescan-devices", "--yes"}; + int const retcode = std::async(std::launch::async, [&] { + return main_impl(cout, cerr, args); + }).get(); + ASSERT_EQ(0, retcode) << cerr.str(); + EXPECT_NE(std::string::npos, cout.str().find("was extended in place")); + } + + // The relocation moved the bytes-used array; a read of a pre-grow key + // cannot tell whether the entries survived, because the nodes have not + // moved. Allocating again is what would land on a chunk the pool wrongly + // believes empty, so upsert onto the grown metadata first. + monad::byte_string const key_after = + monad::byte_string(32, static_cast(0x5a)); + monad::byte_string const value_after = + monad::byte_string(96, static_cast(0xa5)); + { + // append, or the open resets the database it is meant to extend. + monad::mpt::OnDiskDbConfig const config{ + .append = true, + .dbname_path = path0, + .fixed_history_length = MPT_TEST_HISTORY_LENGTH}; + monad::mpt::Db db{std::make_unique(), config}; + auto v0_root = db.load_root_for_version(0); + ASSERT_NE(v0_root, nullptr) + << "version 0's root is unreachable after the grow"; + monad::mpt::UpdateList ul; + auto u = monad::mpt::make_update( + monad::mpt::NibblesView{key_after}, + monad::byte_string_view{value_after}); + ul.push_front(u); + db.upsert(std::move(v0_root), std::move(ul), 1); + } + + std::async(std::launch::async, [&] { + MONAD_ASYNC_NAMESPACE::storage_pool pool{path0}; + EXPECT_GT( + pool.chunks(MONAD_ASYNC_NAMESPACE::storage_pool::seq), + chunks_before); + monad::io::Ring ring; + auto buffers = monad::io::make_buffers_for_read_only( + ring, + 1, + MONAD_ASYNC_NAMESPACE::AsyncIO::MONAD_IO_BUFFERS_READ_SIZE); + MONAD_ASYNC_NAMESPACE::AsyncIO io{pool, buffers}; + monad::mpt::UpdateAux const aux{io}; + EXPECT_GT(aux.metadata_ctx().get_lower_bound_free_space(), free_before); + + monad::mpt::Node::SharedPtr const root_ptr{read_node_blocking( + aux, + aux.metadata_ctx().get_latest_root_offset(), + aux.metadata_ctx().db_history_max_version(), + monad::mpt::timeline_id::primary)}; + monad::mpt::NodeCursor const root(root_ptr); + for (auto const &[label, k] : + {std::pair{"pre-grow", key}, std::pair{"post-grow", key_after}}) { + auto const ret = monad::mpt::find_blocking( + aux, + root, + monad::mpt::NibblesView{k}, + aux.metadata_ctx().db_history_max_version(), + monad::mpt::timeline_id::primary); + EXPECT_EQ(ret.second, monad::mpt::find_result::success) << label; + } + }).get(); +} + +// A pool laid down before db_metadata carried device sizes records none, and +// nothing else can say where an extend left the metadata, so the extend cannot +// be taken up. Zeroing the array reproduces that pool exactly, since zero is +// the not-recorded sentinel. +TEST(cli_tool, rescan_devices_refuses_without_a_recorded_size) +{ + // The refusal below is reached through main_impl, which logs through + // quill: a forked death-test child inherits its queues but not its + // backend thread, and blocks forever on the first log line. Re-exec + // instead, which re-runs this body in the child up to the abort. + testing::FLAGS_gtest_death_test_style = "threadsafe"; + + char path0[] = "cli_tool_tmp_der0_XXXXXX"; + make_temp_pool(path0); + auto const untempfile = + monad::make_scope_exit([&]() noexcept { unlink(path0); }); + unlink_on_abort(path0); + + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + path0, + "--create", + "--root-offsets-chunk-count", + "2", + "--chunk-capacity", + "24"}; + ASSERT_EQ(0, main_impl(cout, cerr, args)); + } + + MONAD_ASYNC_NAMESPACE::file_offset_t size_before = 0; + MONAD_ASYNC_NAMESPACE::file_offset_t half_capacity = 0; + size_t chunks_before = 0; + { + MONAD_ASYNC_NAMESPACE::storage_pool pool{path0}; + chunks_before = pool.chunks(MONAD_ASYNC_NAMESPACE::storage_pool::seq); + size_before = pool.device().size_bytes(); + half_capacity = + pool.chunk(MONAD_ASYNC_NAMESPACE::storage_pool::cnv, 0).capacity() / + 2; + } + + // Both metadata copies, so nothing heals the sentinel back. + { + int const fd = ::open(path0, O_RDWR); + ASSERT_NE(fd, -1); + auto const unfd = + monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + uint64_t const zero = 0; + for (unsigned which = 0; which < 2; which++) { + ASSERT_EQ( + ssize_t(sizeof(zero)), + ::pwrite( + fd, + &zero, + sizeof(zero), + static_cast( + which * half_capacity + + offsetof( + monad::mpt::detail::db_metadata, + recorded_device_size)))); + } + ASSERT_EQ(0, ::fsync(fd)); + + // Read back what is genuinely on disk: if the offset arithmetic were + // wrong the recorded size would survive and answer for itself, and the + // refusal below would never be reached. + for (unsigned which = 0; which < 2; which++) { + uint64_t readback = ~uint64_t{0}; + ASSERT_EQ( + ssize_t(sizeof(readback)), + ::pread( + fd, + &readback, + sizeof(readback), + static_cast( + which * half_capacity + + offsetof( + monad::mpt::detail::db_metadata, + recorded_device_size)))); + ASSERT_EQ(readback, 0u) + << "copy " << which << " still records a device size"; + } + } + + ASSERT_EQ(0, ::truncate(path0, 10ULL * 1024 * 1024 * 1024)); + + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", "--storage", path0, "--rescan-devices", "--yes"}; + ASSERT_DEATH( + { (void)main_impl(cout, cerr, args); }, + "must be opened writable once before its device is extended"); + } + + // Refused before anything was written, so the extend can still be undone. + ASSERT_EQ(0, ::truncate(path0, static_cast(size_before))); + std::async(std::launch::async, [&] { + MONAD_ASYNC_NAMESPACE::storage_pool const pool{path0}; + EXPECT_EQ( + pool.chunks(MONAD_ASYNC_NAMESPACE::storage_pool::seq), + chunks_before); + }).get(); +} + +// A pool carrying no database is the shape a mistyped device argument takes, +// and UpdateAux would initialise one onto it. The refusal has to come before +// that, or the evidence is gone and an identical re-run -- what the help text +// prescribes after an interrupted run -- reports success on the empty database +// the first attempt created. +TEST(cli_tool, rescan_devices_refuses_a_pool_without_a_database) +{ + using monad::mpt::detail::db_metadata; + + char path0[] = "cli_tool_tmp_nodb0_XXXXXX"; + make_temp_pool(path0); + auto const untempfile = + monad::make_scope_exit([&]() noexcept { unlink(path0); }); + { + MONAD_ASYNC_NAMESPACE::storage_pool::creation_flags flags; + flags.set_chunk_capacity(24); + MONAD_ASYNC_NAMESPACE::storage_pool const pool{ + std::filesystem::path{path0}, + MONAD_ASYNC_NAMESPACE::storage_pool::mode::create_if_needed, + flags}; + } + ASSERT_FALSE(device_carries_db_metadata(path0)); + + for (unsigned attempt = 0; attempt < 2; attempt++) { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", "--storage", path0, "--rescan-devices", "--yes"}; + EXPECT_EQ(1, main_impl(cout, cerr, args)) << "attempt " << attempt; + EXPECT_NE(cerr.str().find("holds no database"), std::string::npos) + << "attempt " << attempt << ": " << cerr.str(); + EXPECT_FALSE(device_carries_db_metadata(path0)) + << "attempt " << attempt << " initialised a database"; + } +} + +TEST(cli_tool, rescan_devices_argument_cross_checks) +{ + char path0[] = "cli_tool_tmp_axc0_XXXXXX"; + make_temp_pool(path0); + auto const untempfile = + monad::make_scope_exit([&]() noexcept { unlink(path0); }); + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + path0, + "--create", + "--root-offsets-chunk-count", + "2", + "--chunk-capacity", + "24"}; + ASSERT_EQ(0, main_impl(cout, cerr, args)); + } + // A mistyped path is reported, not turned into an abort inside the pool. + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + "/nonexistent-device", + "--rescan-devices", + "--yes"}; + EXPECT_NE(0, main_impl(cout, cerr, args)); + EXPECT_NE(std::string::npos, cerr.str().find("cannot examine")); + } + // Mutually exclusive with the other mutating operations. + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + path0, + "--rescan-devices", + "--truncate", + "--yes"}; + EXPECT_NE(0, main_impl(cout, cerr, args)); + } + // --restore sits outside the exclusive group and sets truncate_database, + // so it needs its own refusal or it would destroy the pool. + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + path0, + "--rescan-devices", + "--restore", + "/nonexistent-archive", + "--yes"}; + EXPECT_NE(0, main_impl(cout, cerr, args)); + EXPECT_NE( + std::string::npos, + cerr.str().find("cannot be combined with --restore")); + } + // --archive sits outside the exclusive group too and shares the guard. + { + std::stringstream cout; + std::stringstream cerr; + std::string_view args[] = { + "monad-mpt", + "--storage", + path0, + "--rescan-devices", + "--archive", + "/nonexistent-archive-dest", + "--yes"}; + EXPECT_NE(0, main_impl(cout, cerr, args)); + EXPECT_NE( + std::string::npos, + cerr.str().find("cannot be combined with --restore")); + } +} diff --git a/category/mpt/test/db_metadata_test.cpp b/category/mpt/test/db_metadata_test.cpp index 77b83156e0..44d3f01d2a 100644 --- a/category/mpt/test/db_metadata_test.cpp +++ b/category/mpt/test/db_metadata_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -129,13 +130,15 @@ TEST(db_metadata, ring_b_layout_matches_ring_a) // Layout order: root_offsets → root_offsets_state → ... → // secondary_timeline → secondary_timeline_state → primary_ring_idx → // secondary_timeline_active_ → reserved_timeline_[14] → - // pending_shrink_grow → future_variables_unused. + // pending_shrink_grow → recorded_device_size → + // future_variables_unused. auto const offset_secondary = offsetof(md, secondary_timeline); auto const offset_secondary_state = offsetof(md, secondary_timeline_state); auto const offset_primary_ring_idx = offsetof(md, primary_ring_idx); auto const offset_active = offsetof(md, secondary_timeline_active_); auto const offset_reserved = offsetof(md, reserved_timeline_); auto const offset_pending = offsetof(md, pending_shrink_grow); + auto const offset_recorded_size = offsetof(md, recorded_device_size); auto const offset_future = offsetof(md, future_variables_unused); EXPECT_EQ( offset_secondary_state - offset_secondary, @@ -147,7 +150,13 @@ TEST(db_metadata, ring_b_layout_matches_ring_a) EXPECT_EQ(offset_reserved, offset_active + 1); EXPECT_EQ(offset_pending, offset_reserved + 14); EXPECT_EQ( - offset_future, offset_pending + sizeof(md::pending_shrink_grow_t)); + offset_recorded_size, + offset_pending + sizeof(md::pending_shrink_grow_t)); + EXPECT_EQ(offset_future, offset_recorded_size + sizeof(uint64_t)); + // recorded_device_size reads as not-recorded on a pool that predates + // it only while it stays inside the window the MONAD007 migration zeroes. + EXPECT_GT(offset_recorded_size, offset_secondary); + EXPECT_LT(offset_recorded_size, offsetof(md, free_list)); } TEST(db_metadata, role_bytes_zero_initialized) diff --git a/category/mpt/test/db_metadata_test_access.hpp b/category/mpt/test/db_metadata_test_access.hpp index d396c16966..356004fb7d 100644 --- a/category/mpt/test/db_metadata_test_access.hpp +++ b/category/mpt/test/db_metadata_test_access.hpp @@ -16,8 +16,10 @@ #pragma once #include +#include #include +#include #include MONAD_MPT_NAMESPACE_BEGIN @@ -60,6 +62,17 @@ namespace test return r.storage_; } }; + + // Test-only access to the device-add growth internals. Offline, + // single-threaded use only. + struct AddDevicesTestAccess + { + static void + check_chunk_info_fits(size_t const target, size_t const mmap_size) + { + DbMetadataContext::check_chunk_info_fits_(target, mmap_size); + } + }; } MONAD_MPT_NAMESPACE_END diff --git a/category/mpt/test/rescan_death_no_mode.cpp b/category/mpt/test/rescan_death_no_mode.cpp new file mode 100644 index 0000000000..5493156259 --- /dev/null +++ b/category/mpt/test/rescan_death_no_mode.cpp @@ -0,0 +1,85 @@ +// Copyright (C) 2025-26 Category Labs, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#include "rescan_devices_test_util.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include + +using namespace MONAD_ASYNC_NAMESPACE; +using rescan_test::BLKSIZE; +using rescan_test::create_temp_file; +using rescan_test::extend_in_place; +using rescan_test::opened_db; +using rescan_test::rescan_flags; + +namespace +{ + // This test's designed outcome is an abort, so a scope guard would never + // run and the fixture -- half a gigabyte of real blocks once the pool is + // created -- would be left behind on every run. + char fixture_path[4096]; + + extern "C" void unlink_fixture_then_die(int const sig) + { + if (fixture_path[0] != '\0') { + (void)::unlink(fixture_path); + } + (void)::signal(sig, SIG_DFL); + (void)::raise(sig); + } +} + +// A pool grown at the storage layer but opened without mode::rescan must +// abort telling the operator to run monad-mpt --rescan-devices. Its own +// executable because it aborts while holding io_uring rings, which in-process +// death tests handle poorly. +TEST(rescan_death, growth_refused_without_rescan_mode) +{ + monad::start_logger_minimal(); + + auto const dev0 = create_temp_file(20 * BLKSIZE); + auto const undev = monad::make_scope_exit( + [&]() noexcept { std::filesystem::remove(dev0); }); + MONAD_ASSERT(dev0.native().size() < sizeof(fixture_path)); + std::strncpy(fixture_path, dev0.c_str(), sizeof(fixture_path) - 1); + (void)::signal(SIGABRT, unlink_fixture_then_die); + file_offset_t recorded = 0; + { + opened_db const db{dev0, storage_pool::mode::create_if_needed}; + recorded = db.aux.metadata_ctx().main()->recorded_device_size; + } + extend_in_place(dev0, 30 * BLKSIZE + 16384); + { + // The pool takes up the new space, but only monad-mpt's own + // mode::rescan open is allowed to grow the database's chunk_info[]. + storage_pool const pool{ + dev0, storage_pool::mode::rescan, rescan_flags(recorded)}; + } + std::cout << "Must fail after this:" << std::endl; + opened_db const db{dev0, storage_pool::mode::open_existing}; +} diff --git a/category/mpt/test/rescan_devices_test.cpp b/category/mpt/test/rescan_devices_test.cpp new file mode 100644 index 0000000000..5a9e8d88cc --- /dev/null +++ b/category/mpt/test/rescan_devices_test.cpp @@ -0,0 +1,418 @@ +// Copyright (C) 2025-26 Category Labs, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#include "db_metadata_test_access.hpp" +#include "rescan_devices_test_util.hpp" + +#include +#include +#include +#include +#include // NOLINT +#include +#include + +#include + +#include +#include +#include +#include + +#include + +using namespace MONAD_ASYNC_NAMESPACE; +using namespace MONAD_MPT_NAMESPACE; +using rescan_test::BLKSIZE; +using rescan_test::create_temp_file; +using rescan_test::extend_in_place; +using rescan_test::free_list_ids; +using rescan_test::opened_db; +using rescan_test::rescan_flags; + +namespace +{ + // Byte offset of recorded_device_size within metadata copy `which`, + // from the base of cnv chunk 0. + off_t device_size_offset( + file_offset_t const base_offset, file_offset_t const half_capacity, + unsigned const which) + { + return static_cast( + base_offset + which * half_capacity + + offsetof( + MONAD_MPT_NAMESPACE::detail::db_metadata, + recorded_device_size)); + } + + struct cnv0_geometry + { + file_offset_t base_offset; + file_offset_t half_capacity; + }; + + cnv0_geometry geometry_of(storage_pool &pool) + { + auto cnv = pool.chunk(storage_pool::cnv, 0); + auto const fdr = cnv.read_fd(); + auto const fdw = cnv.write_fd(0); + MONAD_ASSERT(fdr.second == fdw.second); + return {fdr.second, cnv.capacity() / 2}; + } +} + +TEST(rescan_devices, records_device_size_on_writable_open) +{ + auto const dev0 = create_temp_file(20 * BLKSIZE); + auto const undev = monad::make_scope_exit( + [&]() noexcept { std::filesystem::remove(dev0); }); + + opened_db db{dev0, storage_pool::mode::create_if_needed}; + for (unsigned which = 0; which < 2; which++) { + auto const *const m = db.aux.metadata_ctx().main(which); + EXPECT_EQ(m->recorded_device_size, db.pool.device().size_bytes()) + << "copy " << which; + } +} + +// The recorded size is what a grow reads back, so an open which is not +// allowed to mutate the database must not touch it, and the next writable +// open must repair whatever it finds. +TEST(rescan_devices, read_only_open_leaves_recorded_device_size_alone) +{ + auto const dev0 = create_temp_file(20 * BLKSIZE); + auto const undevs = monad::make_scope_exit( + [&]() noexcept { std::filesystem::remove(dev0); }); + + file_offset_t real_size = 0; + file_offset_t base_offset = 0; + file_offset_t half_capacity = 0; + { + opened_db db{dev0, storage_pool::mode::create_if_needed}; + real_size = db.pool.device().size_bytes(); + auto const g = geometry_of(db.pool); + base_offset = g.base_offset; + half_capacity = g.half_capacity; + } + ASSERT_GT(real_size, 0u); + + // Poison both copies behind the pool's back, leaving the dirty flag + // clear so the next open is a plain one. + constexpr uint64_t poison = 0xdeadbeefdeadbeefULL; + { + storage_pool pool{dev0, storage_pool::mode::open_existing}; + auto const fdw = pool.chunk(storage_pool::cnv, 0).write_fd(0); + for (unsigned which = 0; which < 2; which++) { + ASSERT_EQ( + ssize_t(sizeof(poison)), + ::pwrite( + fdw.first, + &poison, + sizeof(poison), + device_size_offset(base_offset, half_capacity, which))); + } + ASSERT_EQ(0, ::fsync(fdw.first)); + } + + { + storage_pool::creation_flags flags; + flags.open_read_only = true; + opened_db db{dev0, storage_pool::mode::open_existing, flags}; + for (unsigned which = 0; which < 2; which++) { + EXPECT_EQ( + db.aux.metadata_ctx().main(which)->recorded_device_size, poison) + << "a read-only open rewrote the recorded size on copy " + << which; + } + } + + opened_db db{dev0, storage_pool::mode::open_existing}; + for (unsigned which = 0; which < 2; which++) { + EXPECT_EQ( + db.aux.metadata_ctx().main(which)->recorded_device_size, real_size) + << "copy " << which; + } +} + +TEST(rescan_devices, grows_chunk_info_and_free_list) +{ + auto const dev0 = create_temp_file(20 * BLKSIZE); + auto const undev = monad::make_scope_exit( + [&]() noexcept { std::filesystem::remove(dev0); }); + file_offset_t recorded = 0; + + uint32_t before_count = 0; + uint64_t before_capacity = 0; + std::vector before_free; + { + opened_db db{dev0, storage_pool::mode::create_if_needed}; + auto const *m = db.aux.metadata_ctx().main(); + before_count = static_cast(m->chunk_info_count); + before_capacity = m->capacity_in_free_list; + before_free = free_list_ids(m); + recorded = m->recorded_device_size; + } + ASSERT_GT(before_count, 0u); + extend_in_place(dev0, 30 * BLKSIZE + 16384); + + opened_db db{dev0, storage_pool::mode::rescan, rescan_flags(recorded)}; + auto const *m0 = db.aux.metadata_ctx().main(0); + auto const *m1 = db.aux.metadata_ctx().main(1); + auto const after_count = static_cast(m0->chunk_info_count); + EXPECT_EQ(after_count, db.io.chunk_count()); + EXPECT_GT(after_count, before_count); + EXPECT_EQ(uint32_t(m1->chunk_info_count), after_count); + + // The old free-list order is preserved and the new ids follow it, in + // ascending order, at the tail. + auto const after_free = free_list_ids(m0); + ASSERT_EQ( + after_free.size(), before_free.size() + (after_count - before_count)); + for (size_t n = 0; n < before_free.size(); n++) { + EXPECT_EQ(after_free[n], before_free[n]) << "free slot " << n; + } + for (uint32_t n = before_count; n < after_count; n++) { + EXPECT_EQ(after_free[before_free.size() + (n - before_count)], n); + } + EXPECT_EQ(free_list_ids(m1), after_free); + + // Insertion counts stay monotone across the splice point, which is what + // chunk_list_and_age relies on. + for (size_t n = 1; n < after_free.size(); n++) { + EXPECT_EQ( + uint32_t(m0->at(after_free[n])->insertion_count()), + uint32_t(m0->at(after_free[n - 1])->insertion_count()) + 1) + << "free slot " << n; + } + + // Free capacity grew by exactly the new chunks' capacity. + uint64_t added = 0; + for (uint32_t n = before_count; n < after_count; n++) { + added += db.pool.chunk(storage_pool::seq, n).capacity(); + } + EXPECT_EQ(m0->capacity_in_free_list, before_capacity + added); + EXPECT_EQ(m1->capacity_in_free_list, before_capacity + added); + EXPECT_EQ( + m0->pending_shrink_grow.op_kind, + MONAD_MPT_NAMESPACE::detail::db_metadata::PENDING_OP_NONE); + + // The grow recorded the device's new size, so a second extend has a + // previous size to be taken up from. + EXPECT_EQ(m0->recorded_device_size, db.pool.device().size_bytes()); + EXPECT_EQ(m1->recorded_device_size, db.pool.device().size_bytes()); +} + +TEST(rescan_devices, reopening_a_grown_pool_changes_nothing) +{ + auto const dev0 = create_temp_file(20 * BLKSIZE); + auto const undev = monad::make_scope_exit( + [&]() noexcept { std::filesystem::remove(dev0); }); + file_offset_t recorded = 0; + { + opened_db const db{dev0, storage_pool::mode::create_if_needed}; + recorded = db.aux.metadata_ctx().main()->recorded_device_size; + } + extend_in_place(dev0, 30 * BLKSIZE + 16384); + + uint32_t count = 0; + uint64_t capacity = 0; + std::vector free_ids; + { + opened_db db{dev0, storage_pool::mode::rescan, rescan_flags(recorded)}; + auto const *m = db.aux.metadata_ctx().main(); + count = static_cast(m->chunk_info_count); + capacity = m->capacity_in_free_list; + free_ids = free_list_ids(m); + } + opened_db db{dev0, storage_pool::mode::open_existing}; + auto const *m = db.aux.metadata_ctx().main(); + EXPECT_EQ(uint32_t(m->chunk_info_count), count); + EXPECT_EQ(m->capacity_in_free_list, capacity); + EXPECT_EQ(free_list_ids(m), free_ids); +} + +// Reproduces the crash window between grow_chunk_info_body_'s two per-copy +// scopes: pending record stamped, copy 0 grown, copy 1 not, neither copy +// dirty. Built by raw file I/O on cnv chunk 0 rather than by driving +// DbMetadataContext, whose constructor always leaves the two copies agreeing. +// Same technique as provision_monad007_pool in cli_tool_test.cpp. +TEST(rescan_devices, interrupted_growth_replays_on_reopen) +{ + auto const dev0 = create_temp_file(20 * BLKSIZE); + auto const undev = monad::make_scope_exit( + [&]() noexcept { std::filesystem::remove(dev0); }); + file_offset_t recorded = 0; + + // cnv chunk 0 geometry, and copy 1's pre-growth bytes. + file_offset_t base_offset = 0; + file_offset_t half_capacity = 0; + std::vector saved_copy1; + size_t pre_growth_count = 0; + { + opened_db db{dev0, storage_pool::mode::create_if_needed}; + auto cnv = db.pool.chunk(storage_pool::cnv, 0); + auto const fdr = cnv.read_fd(); + auto const fdw = cnv.write_fd(0); + ASSERT_EQ(fdr.second, fdw.second) + << "read/write fds disagree on cnv chunk 0's base offset; the " + "pread/pwrite geometry below would be silently wrong"; + base_offset = fdr.second; + half_capacity = cnv.capacity() / 2; + ASSERT_GT(half_capacity, 0u); + pre_growth_count = db.aux.metadata_ctx().main()->chunk_info_count; + recorded = db.aux.metadata_ctx().main()->recorded_device_size; + saved_copy1.resize(static_cast(half_capacity)); + auto const got = ::pread( + fdr.first, + saved_copy1.data(), + saved_copy1.size(), + static_cast(base_offset + half_capacity)); + ASSERT_EQ(got, ssize_t(saved_copy1.size())); + } + + extend_in_place(dev0, 30 * BLKSIZE + 16384); + + // Complete the grow so copy 0 is fully extended. + size_t grown_count = 0; + { + opened_db db{dev0, storage_pool::mode::rescan, rescan_flags(recorded)}; + grown_count = db.aux.metadata_ctx().main()->chunk_info_count; + } + ASSERT_GT(grown_count, pre_growth_count); + + // Roll copy 1 back to its pre-growth bytes and stamp the pending record + // into both copies, clean. + { + storage_pool pool{dev0, storage_pool::mode::open_existing}; + auto cnv = pool.chunk(storage_pool::cnv, 0); + auto const fdw = cnv.write_fd(0); + ASSERT_EQ( + ssize_t(saved_copy1.size()), + ::pwrite( + fdw.first, + saved_copy1.data(), + saved_copy1.size(), + static_cast(base_offset + half_capacity))); + MONAD_MPT_NAMESPACE::detail::db_metadata::pending_shrink_grow_t const + pending{ + MONAD_MPT_NAMESPACE::detail::db_metadata::PENDING_OP_RESCAN, + static_cast(grown_count)}; + for (unsigned which = 0; which < 2; which++) { + ASSERT_EQ( + ssize_t(sizeof(pending)), + ::pwrite( + fdw.first, + &pending, + sizeof(pending), + static_cast( + base_offset + which * half_capacity + + offsetof( + MONAD_MPT_NAMESPACE::detail::db_metadata, + pending_shrink_grow)))); + } + ASSERT_EQ(0, ::fsync(fdw.first)); + + // Confirm the crash window was actually built, by reading back what + // is genuinely on disk rather than through a mapped + // DbMetadataContext. If this doesn't hold, the reopen below would + // converge trivially (or not exercise replay at all) and the + // assertions past it would pass vacuously. + uint64_t copy1_header_word = 0; + ASSERT_EQ( + ssize_t(sizeof(copy1_header_word)), + ::pread( + fdw.first, + ©1_header_word, + sizeof(copy1_header_word), + static_cast( + base_offset + half_capacity + + MONAD_MPT_NAMESPACE::detail::db_metadata:: + MAGIC_STRING_LEN))); + ASSERT_EQ(copy1_header_word & 0xfffffU, uint64_t(pre_growth_count)) + << "copy 1's chunk_info_count was not rolled back; the crash " + "window was not built"; + for (unsigned which = 0; which < 2; which++) { + MONAD_MPT_NAMESPACE::detail::db_metadata::pending_shrink_grow_t + readback_pending{}; + ASSERT_EQ( + ssize_t(sizeof(readback_pending)), + ::pread( + fdw.first, + &readback_pending, + sizeof(readback_pending), + static_cast( + base_offset + which * half_capacity + + offsetof( + MONAD_MPT_NAMESPACE::detail::db_metadata, + pending_shrink_grow)))); + ASSERT_EQ( + readback_pending.op_kind, + uint32_t(MONAD_MPT_NAMESPACE::detail::db_metadata:: + PENDING_OP_RESCAN)) + << "copy " << which << " does not carry the pending record"; + + uint8_t dirty_byte = 0xff; + ASSERT_EQ( + ssize_t(sizeof(dirty_byte)), + ::pread( + fdw.first, + &dirty_byte, + sizeof(dirty_byte), + static_cast( + base_offset + which * half_capacity + + offsetof( + MONAD_MPT_NAMESPACE::detail::db_metadata, + capacity_in_free_list) - + 1))); + ASSERT_EQ(dirty_byte, 0u) + << "copy " << which + << " is dirty; dirty-bit recovery (not replay) would " + "resolve this window, defeating the point of the test"; + } + } + + // Reopening must replay and converge both copies. + opened_db db{dev0, storage_pool::mode::open_existing}; + auto const *m0 = db.aux.metadata_ctx().main(0); + auto const *m1 = db.aux.metadata_ctx().main(1); + EXPECT_EQ(uint32_t(m0->chunk_info_count), db.io.chunk_count()); + EXPECT_EQ(uint32_t(m1->chunk_info_count), db.io.chunk_count()); + EXPECT_EQ(m0->capacity_in_free_list, m1->capacity_in_free_list); + EXPECT_EQ(free_list_ids(m0), free_list_ids(m1)); + EXPECT_EQ( + m0->pending_shrink_grow.op_kind, + MONAD_MPT_NAMESPACE::detail::db_metadata::PENDING_OP_NONE); + EXPECT_EQ( + m1->pending_shrink_grow.op_kind, + MONAD_MPT_NAMESPACE::detail::db_metadata::PENDING_OP_NONE); +} + +// The bounds are unit tested directly rather than by provisioning a pool of +// hundreds of gigabytes. +TEST(rescan_devices, chunk_info_bounds) +{ + // 4096 chunks at 8 bytes each on top of the MONAD007 header, inside a 1Mb + // half-chunk: fits. + MONAD_MPT_NAMESPACE::test::AddDevicesTestAccess::check_chunk_info_fits( + 4096, 1024 * 1024); + ASSERT_DEATH( + MONAD_MPT_NAMESPACE::test::AddDevicesTestAccess::check_chunk_info_fits( + 0x100000, 1 << 30), + "20 bit chunk id space"); + ASSERT_DEATH( + MONAD_MPT_NAMESPACE::test::AddDevicesTestAccess::check_chunk_info_fits( + 100000, 1024 * 1024), + "conventional chunk 0 only provides"); +} diff --git a/category/mpt/test/rescan_devices_test_util.hpp b/category/mpt/test/rescan_devices_test_util.hpp new file mode 100644 index 0000000000..1ffa7c9a96 --- /dev/null +++ b/category/mpt/test/rescan_devices_test_util.hpp @@ -0,0 +1,151 @@ +// Copyright (C) 2025-26 Category Labs, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace rescan_test +{ + // The fixtures size devices in whole chunks, so this is both the chunk + // capacity and the unit they extend by. Kept small: pool creation + // 0xff-fills the root-offsets ring, so the default 256 Mb capacity costs + // half a gigabyte of real writes per test. + inline constexpr uint32_t CHUNK_CAPACITY_BITS = 24; + inline constexpr MONAD_ASYNC_NAMESPACE::file_offset_t BLKSIZE = + 1ULL << CHUNK_CAPACITY_BITS; + + inline MONAD_ASYNC_NAMESPACE::storage_pool::creation_flags + small_chunk_flags() + { + MONAD_ASYNC_NAMESPACE::storage_pool::creation_flags flags; + flags.set_chunk_capacity(CHUNK_CAPACITY_BITS); + return flags; + } + + inline std::filesystem::path + create_temp_file(MONAD_ASYNC_NAMESPACE::file_offset_t const length) + { + monad::test::remove_stale_temp_files_once( + MONAD_ASYNC_NAMESPACE::working_temporary_directory(), + "monad_rescan_test_"); + std::filesystem::path ret( + MONAD_ASYNC_NAMESPACE::working_temporary_directory() / + "monad_rescan_test_XXXXXX"); + int const fd = ::mkstemp((char *)ret.native().data()); + MONAD_ASSERT(fd != -1); + MONAD_ASSERT(-1 != ::ftruncate(fd, static_cast(length + 16384))); + ::close(fd); + return ret; + } + + inline monad::io::Buffers make_buffers( + bool const read_only, monad::io::Ring &rd_ring, + monad::io::Ring &wr_ring) + { + constexpr size_t rd_size = + MONAD_ASYNC_NAMESPACE::AsyncIO::MONAD_IO_BUFFERS_READ_SIZE; + constexpr size_t wr_size = + MONAD_ASYNC_NAMESPACE::AsyncIO::MONAD_IO_BUFFERS_WRITE_SIZE; + if (read_only) { + return monad::io::make_buffers_for_read_only(rd_ring, 2, rd_size); + } + return monad::io::make_buffers_for_segregated_read_write( + rd_ring, wr_ring, 2, 4, rd_size, wr_size); + } + + // Opens the pool through a full AsyncIO and UpdateAux so the metadata + // constructor runs exactly as it does in production. + struct opened_db + { + MONAD_ASYNC_NAMESPACE::storage_pool pool; + monad::io::Ring rd_ring; + monad::io::Ring wr_ring; + monad::io::Buffers buffers; + MONAD_ASYNC_NAMESPACE::AsyncIO io; + MONAD_MPT_NAMESPACE::UpdateAux aux; + + opened_db( + std::filesystem::path const &dev, + MONAD_ASYNC_NAMESPACE::storage_pool::mode const mode, + MONAD_ASYNC_NAMESPACE::storage_pool::creation_flags const flags = + small_chunk_flags()) + : pool{dev, mode, flags} + , rd_ring{monad::io::RingConfig{2}} + , wr_ring{monad::io::RingConfig{4}} + , buffers{make_buffers(pool.is_read_only(), rd_ring, wr_ring)} + , io{pool, buffers} + , aux{io} + { + } + }; + + // Extends the device in place, exactly as lvextend would. + inline void extend_in_place( + std::filesystem::path const &path, + MONAD_ASYNC_NAMESPACE::file_offset_t const size) + { + int const fd = ::open(path.c_str(), O_RDWR); + MONAD_ASSERT(fd != -1); + auto const unfd = + monad::make_scope_exit([fd]() noexcept { ::close(fd); }); + MONAD_ASSERT(-1 != ::ftruncate(fd, static_cast(size))); + } + + // What monad-mpt hands the pool: the size db_metadata recorded for the + // device, which is the only thing that can locate the stranded metadata. + inline MONAD_ASYNC_NAMESPACE::storage_pool::creation_flags + rescan_flags(MONAD_ASYNC_NAMESPACE::file_offset_t const recorded) + { + auto flags = small_chunk_flags(); + flags.recorded_size_of_grown_device = recorded; + flags.metadata_budget = + MONAD_ASYNC_NAMESPACE::storage_pool::db_metadata_budget{ + .header_bytes = MONAD_MPT_NAMESPACE::detail::db_metadata:: + MONAD007_HEADER_BYTES, + .bytes_per_chunk = sizeof( + MONAD_MPT_NAMESPACE::detail::db_metadata::chunk_info_t)}; + return flags; + } + + // The free list in list order. + inline std::vector + free_list_ids(MONAD_MPT_NAMESPACE::detail::db_metadata const *const m) + { + std::vector ret; + for (auto const *i = m->free_list_begin(); i != nullptr; + i = i->next(m)) { + ret.push_back(i->index(m)); + } + return ret; + } +} diff --git a/category/mpt/test/update_aux_test.cpp b/category/mpt/test/update_aux_test.cpp index 0e43f38c59..101effc9fe 100644 --- a/category/mpt/test/update_aux_test.cpp +++ b/category/mpt/test/update_aux_test.cpp @@ -505,9 +505,12 @@ TEST(update_aux_test, migrates_monad007_layout_to_monad008) uint32_t const test_cnv_chunk_id_1 = 2; uint32_t const test_free_list_begin = 5; uint32_t const test_free_list_end = 7; - uint32_t const test_chunk_count = static_cast( - pool.chunks(monad::async::storage_pool::seq) + - pool.chunks(monad::async::storage_pool::cnv)); + // chunk_info[] indexes sequential chunks only (see + // DbMetadataContext::reconcile_chunk_count_), so the synthetic buffer's + // chunk_info_count must match the pool's seq chunk count exactly, not + // seq + cnv. + uint32_t const test_chunk_count = + static_cast(pool.chunks(monad::async::storage_pool::seq)); auto cnv_chunk = pool.chunk(monad::async::storage_pool::cnv, 0); auto const [write_fd, base_offset] = cnv_chunk.write_fd(0); diff --git a/category/mpt/update_aux.cpp b/category/mpt/update_aux.cpp index 6c4fc0ea46..7b4b569a8e 100644 --- a/category/mpt/update_aux.cpp +++ b/category/mpt/update_aux.cpp @@ -483,9 +483,14 @@ void UpdateAux::init(AsyncIO &io_, std::optional const history_len) } } } - // If the pool has changed since we configured the metadata, this will - // fail - MONAD_ASSERT(metadata_ctx_->main()->chunk_info_count == io->chunk_count()); + // If the pool has changed since we configured the metadata, this will fail + MONAD_ASSERT_PRINTF( + metadata_ctx_->main()->chunk_info_count == io->chunk_count(), + "DB metadata describes %u sequential chunks but the storage pool " + "provides %zu. If the device was extended in place, run " + "'monad-mpt --rescan-devices'.", + unsigned(metadata_ctx_->main()->chunk_info_count), + io->chunk_count()); } void UpdateAux::reset_node_writers()