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()