]> git.hungrycats.org Git - bees/commitdiff
state: add persistent state config and memory-only hash table
authorZygo Blaxell <bees@furryterror.org>
Sun, 26 Apr 2026 03:09:13 +0000 (23:09 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sun, 30 Aug 2026 23:48:46 +0000 (19:48 -0400)
Introduces the [state], [state.hash], and [state.point] config
sections, a memory-only mode for the hash table, and setter APIs
on BeesHashTable and BeesRoots for the new config keys.  Wires the
config keys through bees.cc::bees_main so the daemon's startup
banner sets persistence-related options from the configuration
instead of hardcoded constants or the legacy CLI.

Config sections (src/bees-config-v1.cc):

  * [state]: persistent = yes|no.  When `no`, bees starts fresh
    every run with an in-memory hash table; combine with
    loop.exit-when-finished for stateless one-shot deduplication.

  * [state.hash]: size (accepts expressions using min()/max() and
    ${RAM_BYTES}/${FS_BYTES}), create, resize, writeback-time,
    writeback-rate-max, writeback-fsync, writeback-unreadahead,
    close-fsync.

  * [state.point]: interval, defer.

To support the hash-table size expression, bees-config.cc gains
two subst variables (RAM_BYTES from sysconf, FS_BYTES from
fstatfs) and a recursive-descent expression parser replacing the
old parse_size_token function.  The grammar supports the standard
arithmetic precedence with `+`/`-`/`*`/`/`, parentheses, min(),
max(), and the size suffixes K/M/G/T/P/E plus `%`.  `max` on its
own evaluates to uint64_t's maximum.

BeesHashTable (src/bees-hash.cc, src/bees.h) grows:

  * An m_persistent flag and NoFileTag/NoThreadsTag construction
    tags that allow a RAM-only instance or a file-backed instance
    without background threads.
  * A private init() helper shared by all constructors.
  * A resize_file() helper supporting in-place table resize on
    startup via the reverse-LRU rebuild path.
  * Setter APIs for every [state.hash.*] key.
  * Conditional branches in the writeback and prefetch paths that
    short-circuit file I/O when m_persistent is false.
  * An m_writeback_extent_count atomic so the crawl-checkpoint
    defer path can observe progress.

BeesRoots (src/bees.h, src/bees-roots.cc) grows three setter APIs
(set_checkpoint_interval, set_persistent, set_checkpoint_defer)
and the backing members
(m_checkpoint_interval/m_roots_persistent/m_checkpoint_defer).
Actual checkpoint deferral logic (snapshot_point,
flush_pending_checkpoints, the BeesCrawl idle semantics, etc.)
lands in a later round; this commit only provides the
setters that bees.cc needs to call at startup.

bees.cc::bees_main gets a state-setup block that reads every
[state.*] key, builds the hash table in either persistent or
memory-only mode, and configures the crawl-checkpoint policy.

Assisted-by: Claude-Code:claude-opus-4-7
Signed-off-by: Zygo Blaxell <bees@furryterror.org>
src/bees-config-v1.cc
src/bees-config.cc
src/bees-context.cc
src/bees-hash.cc
src/bees-roots.cc
src/bees.cc
src/bees.h

index 9a42ee2208740b38bd95a87f4b6b20533bde5e14..13e7d33544d60b0505202760a22f6be1f3b8d93f 100644 (file)
@@ -292,6 +292,70 @@ static const char bees_config_v1[] = R"--v1-config--(
         # Acts as a ceiling on the computed polling rate.
         poll-max = 1h
 
+# The [state] section controls hash table and crawl checkpoint persistence.
+
+[state]
+
+        # Enable persistent state (hash table and crawl checkpoints).
+        # When set to no, bees starts fresh every run with an in-memory
+        # hash table.
+        persistent = yes
+
+# The [state.hash] section controls hash table file options.
+
+[state.hash]
+
+        # Hash table size in bytes.  Larger tables store more hashes and
+        # find more duplicates.  The default uses the smaller of RAM/16
+        # and filesystem-capacity/16384, rounded up to the next 128 KiB
+        # hash-table extent boundary.
+        #
+        # Size value; accepts ${RAM_BYTES}, ${FS_BYTES}, and
+        # other substitutions (see docs/config-format.md).
+        size = min(${RAM_BYTES} / 16, ${FS_BYTES} / 16384)
+
+        # Create beeshash.dat if it does not exist.
+        # Set to no to require the file to be present at startup.
+        create = yes
+
+        # Resize the on-disk hash table to state.hash.size on startup.
+        # Uses reverse-LRU rebuild to preserve the most-recently-used entries.
+        # When no, a size mismatch is logged as a warning and the existing
+        # file size is used instead.
+        resize = no
+
+        # Time budget for writing the full hash table to disk during
+        # normal operation.  Limited by writeback-rate-max.
+        writeback-time = 2h
+
+        # Maximum hash table writeback rate in bytes per second.  The
+        # effective rate is min(size / writeback-time, writeback-rate-max).
+        writeback-rate-max = 128K
+
+        # fsync after each hash table extent write.  Provides a small
+        # increase in durability at the cost of frequent committed writes.
+        writeback-fsync = no
+
+        # Evict hash table pages from the VFS page cache after each write.
+        # Avoids flooding the page cache with hash table data.
+        writeback-unreadahead = no
+
+        # fsync the hash table file when bees exits.
+        close-fsync = no
+
+# The [state.point] section controls crawl checkpoint options.
+
+[state.point]
+
+        # Interval in seconds between crawl checkpoint writes.
+        interval = 900s
+
+        # Defer crawl checkpoint writes until the hash table writeback has
+        # flushed all extents to disk that were modified since the checkpoint update.
+        # Prevents beespoint.ini from advancing past beeshash.dat after a
+        # crash, which would cause missed deduplication on restart.
+        defer = no
+
 # The [scan.subvol] section controls legacy subvol crawlers (scan modes 0-3).
 
 [scan.subvol]
@@ -299,7 +363,8 @@ static const char bees_config_v1[] = R"--v1-config--(
         # Subvol scans inactive by default.
         active = no
 
-        # Subvol scan mode: 0=lockstep, 1=independent, 2=sequential, 3=recent (legacy -m values 0-3).
+        # Subvol scan mode: 0=lockstep, 1=independent, 2=sequential,
+        # 3=recent (legacy -m values 0-3).
         mode = 3
 
         # Skip read-only subvols entirely.
index a8f94f1d69d2933d0f1dd0e2d3f9842c42641183..a6f4c26040618ac527ca5b62bfa7b2fb9a8308d6 100644 (file)
@@ -4,11 +4,14 @@
 
 #include "crucible/error.h"
 #include "crucible/fs.h"
+#include "crucible/limits.h"
 #include "crucible/path.h"
 #include "crucible/string.h"
 #include "crucible/uuid.h"
 
+#include <cmath>
 #include <sstream>
+#include <sys/statfs.h>
 
 #define THROW_CONFIG_ERROR(__key, __value, __message) do { \
        ostringstream __oss; \
@@ -59,6 +62,19 @@ BeesConfig::BeesConfig(const string &path, const Fd &fd) :
        }
        insert_map_unique(m_subst_map, "LABEL", fslabel);
 
+       BEESTRACE("Getting RAM_BYTES from sysconf");
+       const auto phys_pages = sysconf(_SC_PHYS_PAGES);
+       const auto page_size  = sysconf(_SC_PAGE_SIZE);
+       THROW_CHECK2(runtime_error, phys_pages, page_size, phys_pages > 0 && page_size > 0);
+       const auto ram_bytes = static_cast<uint64_t>(phys_pages) * static_cast<uint64_t>(page_size);
+       insert_map_unique(m_subst_map, "RAM_BYTES", to_string(ram_bytes));
+
+       BEESTRACE("Getting FS_BYTES from " << name_fd(m_root_fd));
+       struct statfs fs_stat = {};
+       DIE_IF_NON_ZERO(fstatfs(m_root_fd, &fs_stat));
+       const auto fs_bytes = ranged_cast<uint64_t>(fs_stat.f_blocks) * ranged_cast<uint64_t>(fs_stat.f_frsize);
+       insert_map_unique(m_subst_map, "FS_BYTES", to_string(fs_bytes));
+
        for (const auto &i : m_subst_map) {
                BEESLOGDEBUG("subst map: '" << i.first << "' = '" << i.second << "'");
        }
@@ -240,76 +256,178 @@ bees_parse_bool(const string& str)
        THROW_ERROR(invalid_argument, "'" << str << "' not recognized as bool value");
 }
 
-/// Parse a single size token (digits with optional K/M/G/T/P/E suffix).
-static
-uint64_t
-parse_size_token(const string &tok, const string &context)
-{
-       if (tok.empty()) {
-               THROW_ERROR(invalid_argument, "empty size value in '" << context << "'");
+/// Recursive descent parser for size expressions.
+/// Grammar (standard arithmetic precedence):
+///   expr     := add_expr
+///   add_expr := mul_expr ( ('+' | '-') mul_expr )*
+///   mul_expr := atom ( ('*' | '/') atom )*
+///   atom     := NUMBER SUFFIX?
+///             | '(' expr ')'
+///             | 'min' '(' expr ',' expr ')'
+///             | 'max' '(' expr ',' expr ')'
+///             | 'max'                          -- numeric_limits<uint64_t>::max()
+///   SUFFIX   := K|M|G|T|P|E (powers of 1024) | '%' (divide by 100)
+///
+/// All arithmetic uses double; the final result is cast to uint64_t.
+/// 'max' alone evaluates to infinity, which maps to uint64_t max on exit.
+namespace {
+struct SizeExprParser {
+       const string &input;
+       size_t pos;
+
+       explicit SizeExprParser(const string &s) : input(s), pos(0) {}
+
+       void skip_ws() {
+               while (pos < input.size() && isspace(static_cast<unsigned char>(input[pos])))
+                       ++pos;
        }
 
-       const char suffix = tok.back();
-       uint64_t multiplier = 1;
-       string digits = tok;
-       switch (toupper(static_cast<unsigned char>(suffix))) {
-               case 'K': multiplier = 1ULL << 10; break;
-               case 'M': multiplier = 1ULL << 20; break;
-               case 'G': multiplier = 1ULL << 30; break;
-               case 'T': multiplier = 1ULL << 40; break;
-               case 'P': multiplier = 1ULL << 50; break;
-               case 'E': multiplier = 1ULL << 60; break;
-               default:
-                       THROW_CHECK1(invalid_argument, suffix, isdigit(suffix));
-                       break;
+       bool at_end() { skip_ws(); return pos >= input.size(); }
+       char peek()   { skip_ws(); return pos < input.size() ? input[pos] : '\0'; }
+
+       bool try_consume(char c) {
+               if (peek() == c) { ++pos; return true; }
+               return false;
        }
-       if (multiplier != 1) {
-               digits = tok.substr(0, tok.size() - 1);
+
+       double parse_expr() { return parse_add(); }
+
+       double parse_add() {
+               double v = parse_mul();
+               while (true) {
+                       if (try_consume('+')) {
+                               v += parse_mul();
+                       } else if (peek() == '-') {
+                               ++pos;
+                               v -= parse_mul();
+                       } else {
+                               break;
+                       }
+               }
+               return v;
        }
 
-       return stoull(digits) * multiplier;
-}
+       double parse_mul() {
+               double v = parse_atom();
+               while (true) {
+                       if (try_consume('*')) {
+                               v *= parse_atom();
+                       } else if (try_consume('/')) {
+                               const double d = parse_atom();
+                               THROW_CHECK1(invalid_argument, d, d != 0.0);
+                               v /= d;
+                       } else {
+                               break;
+                       }
+               }
+               return v;
+       }
+
+       double parse_atom() {
+               skip_ws();
+               if (pos >= input.size()) {
+                       THROW_ERROR(invalid_argument, "unexpected end of size expression '" << input << "'");
+               }
+
+               // Parenthesized sub-expression
+               if (input[pos] == '(') {
+                       ++pos;
+                       const double v = parse_expr();
+                       skip_ws();
+                       if (pos >= input.size() || input[pos] != ')') {
+                               THROW_ERROR(invalid_argument, "missing ')' in size expression '" << input << "'");
+                       }
+                       ++pos;
+                       return v;
+               }
+
+               // Identifier: 'min'/'max' functions or 'max' sentinel
+               if (isalpha(static_cast<unsigned char>(input[pos]))) {
+                       const size_t id_start = pos;
+                       while (pos < input.size() && isalpha(static_cast<unsigned char>(input[pos])))
+                               ++pos;
+                       const string ident = input.substr(id_start, pos - id_start);
+
+                       skip_ws();
+                       if (pos < input.size() && input[pos] == '(') {
+                               ++pos;
+                               const double a = parse_expr();
+                               skip_ws();
+                               if (pos >= input.size() || input[pos] != ',') {
+                                       THROW_ERROR(invalid_argument, "expected ',' in '" << ident << "()' in '" << input << "'");
+                               }
+                               ++pos;
+                               const double b = parse_expr();
+                               skip_ws();
+                               if (pos >= input.size() || input[pos] != ')') {
+                                       THROW_ERROR(invalid_argument, "missing ')' after '" << ident << "()' in '" << input << "'");
+                               }
+                               ++pos;
+                               if (ident == "min") return min(a, b);
+                               if (ident == "max") return max(a, b);
+                               THROW_ERROR(invalid_argument, "unknown function '" << ident << "' in '" << input << "'");
+                       }
+
+                       // 'max' as standalone keyword = uint64_t max
+                       if (ident == "max") {
+                               return numeric_limits<double>::infinity();
+                       }
+
+                       THROW_ERROR(invalid_argument, "unexpected identifier '" << ident << "' in size expression '" << input << "'");
+               }
+
+               // Numeric literal with optional size or percentage suffix
+               if (isdigit(static_cast<unsigned char>(input[pos])) || input[pos] == '.') {
+                       const size_t num_start = pos;
+                       while (pos < input.size() &&
+                              (isdigit(static_cast<unsigned char>(input[pos])) || input[pos] == '.'))
+                               ++pos;
+                       double value = stod(input.substr(num_start, pos - num_start));
+
+                       if (pos < input.size()) {
+                               const char raw    = input[pos];
+                               const char suffix = static_cast<char>(toupper(static_cast<unsigned char>(raw)));
+                               switch (suffix) {
+                                       case 'K': value *= double(1ULL << 10); ++pos; break;
+                                       case 'M': value *= double(1ULL << 20); ++pos; break;
+                                       case 'G': value *= double(1ULL << 30); ++pos; break;
+                                       case 'T': value *= double(1ULL << 40); ++pos; break;
+                                       case 'P': value *= double(1ULL << 50); ++pos; break;
+                                       case 'E': value *= double(1ULL << 60); ++pos; break;
+                                       case '%': value /= 100.0;              ++pos; break;
+                                       default:  break;
+                               }
+                       }
+                       return value;
+               }
+
+               THROW_ERROR(invalid_argument, "unexpected character '" << input[pos] << "' in size expression '" << input << "'");
+       }
+};
+} // anonymous namespace
 
 uint64_t
 bees_parse_size(const string &s)
 {
        BEESTRACE("parsing size '" << s << "'");
-       if (s == "max") {
-               return numeric_limits<uint64_t>::max();
-       }
 
-       // Find a '+' or '-' operator after the base value (e.g. "512K + 1",
-       // "128K - 4K").  The operand may also carry a scale suffix.
-       string base = s;
-       char op = 0;
-       string operand;
-       for (auto c : "+-") {
-               const auto pos = s.find(c);
-               if (pos != string::npos) {
-                       op = c;
-                       base = s.substr(0, pos);
-                       operand = s.substr(pos + 1);
-                       break;
-               }
+       SizeExprParser parser(s);
+       if (parser.at_end()) {
+               THROW_ERROR(invalid_argument, "empty size expression");
        }
 
-       // Trim whitespace around both tokens.
-       while (!base.empty() && isspace(static_cast<unsigned char>(base.back()))) base.pop_back();
-       while (!operand.empty() && isspace(static_cast<unsigned char>(operand.front()))) operand.erase(operand.begin());
-
-       const uint64_t base_val = parse_size_token(base, s);
+       const double result = parser.parse_expr();
 
-       if (!op) {
-               return base_val;
+       if (!parser.at_end()) {
+               THROW_ERROR(invalid_argument, "unexpected trailing characters in size expression '" << s << "'");
        }
 
-       const uint64_t operand_val = parse_size_token(operand, s);
-       if (op == '+') {
-               return base_val + operand_val;
+       if (isinf(result)) {
+               return numeric_limits<uint64_t>::max();
        }
-       // op == '-'
-       THROW_CHECK2(invalid_argument, base_val, operand_val, base_val >= operand_val);
-       return base_val - operand_val;
+
+       THROW_CHECK1(invalid_argument, result, result >= 0.0);
+       return static_cast<uint64_t>(result);
 }
 
 uint64_t
index 3b29a19cde90ef72c8ff62c6301fb4a6fcf011e7..92d2c107ba8acd9cbd755a93f7f8e38de3f97646 100644 (file)
@@ -1396,12 +1396,49 @@ shared_ptr<BeesHashTable>
 BeesContext::hash_table()
 {
        unique_lock<mutex> lock(m_stop_mutex);
-       if (!m_hash_table) {
-               m_hash_table = make_shared<BeesHashTable>(shared_from_this(), "beeshash.dat");
-       }
+       THROW_CHECK0(runtime_error, m_hash_table);
        return m_hash_table;
 }
 
+void
+BeesContext::init_hash_table(const string &filename, off_t size)
+{
+       unique_lock<mutex> lock(m_stop_mutex);
+       m_hash_table = make_shared<BeesHashTable>(shared_from_this(), filename, size, BeesHashTable::NoThreadsTag{});
+}
+
+void
+BeesContext::init_hash_table_memory(off_t size)
+{
+       unique_lock<mutex> lock(m_stop_mutex);
+       m_hash_table = make_shared<BeesHashTable>(shared_from_this(), size, BeesHashTable::NoFileTag{});
+}
+
+void
+BeesContext::start_hash_writeback()
+{
+       hash_table()->start_threads();
+}
+
+void
+BeesContext::flush_hash_table()
+{
+       hash_table()->flush();
+}
+
+void
+BeesContext::start_crawl()
+{
+       roots()->start();
+}
+
+void
+BeesContext::stop_crawl()
+{
+       roots()->stop_request();
+       roots()->stop_wait();
+}
+
 void
 BeesContext::set_root_path(const string& path)
 {
index 7581fe14d1338d35e79ee1d70f5d9fe90c7bfed7..eaa4e48950542c862aa8526c4a5cb010f0a06d42 100644 (file)
@@ -108,27 +108,37 @@ BeesHashTable::flush_dirty_extent(uint64_t extent_index)
        auto lock = lock_extent_by_index(extent_index);
        bool wrote_extent = false;
 
+       if (!m_persistent) {
+               // Memory-only mode: mark clean without any file I/O.
+               m_extent_metadata.at(extent_index).m_dirty = false;
+               return true;
+       }
+
        catch_all([&]() {
                uint8_t *const dirty_extent      = m_extent_ptr[extent_index].p_byte;
                uint8_t *const dirty_extent_end  = m_extent_ptr[extent_index + 1].p_byte;
                const size_t dirty_extent_offset = dirty_extent - m_byte_ptr;
+               const size_t dirty_extent_size   = dirty_extent_end - dirty_extent;
                THROW_CHECK1(out_of_range, dirty_extent,     dirty_extent     >= m_byte_ptr);
                THROW_CHECK1(out_of_range, dirty_extent_end, dirty_extent_end <= m_byte_ptr_end);
                THROW_CHECK2(out_of_range, dirty_extent_end, dirty_extent, dirty_extent_end - dirty_extent == BLOCK_SIZE_HASHTAB_EXTENT);
-               BEESTOOLONG("pwrite(fd " << m_fd << " '" << name_fd(m_fd)<< "', length " << to_hex(dirty_extent_end - dirty_extent) << ", offset " << to_hex(dirty_extent - m_byte_ptr) << ")");
+               BEESTOOLONG("pwrite(fd " << m_fd << " '" << name_fd(m_fd)<< "', length " << to_hex(dirty_extent_size) << ", offset " << to_hex(dirty_extent_offset) << ")");
                // Copy the extent because we might be stuck writing for a while
                ByteVector extent_copy(dirty_extent, dirty_extent_end);
 
                // Release the lock
                lock.unlock();
 
-               // Write the extent (or not)
+               // Write the extent
                pwrite_or_die(m_fd, extent_copy, dirty_extent_offset);
                BEESCOUNT(hash_extent_out);
 
-               // Nope, this causes a _dramatic_ loss of performance.
-               // const size_t dirty_extent_size   = dirty_extent_end - dirty_extent;
-               // bees_unreadahead(m_fd, dirty_extent_offset, dirty_extent_size);
+               if (m_writeback_unreadahead) {
+                       bees_unreadahead(m_fd, dirty_extent_offset, dirty_extent_size);
+               }
+               if (m_writeback_fsync) {
+                       bees_fsync(m_fd);
+               }
 
                // Mark extent clean if write was successful
                lock.lock();
@@ -150,12 +160,14 @@ BeesHashTable::flush_dirty_extents(bool slowly)
                // Skip the clean ones
                auto lock = lock_extent_by_index(extent_index);
                if (!m_extent_metadata.at(extent_index).m_dirty) {
+                       ++m_writeback_extent_count;
                        continue;
                }
                lock.unlock();
 
                if (flush_dirty_extent(extent_index)) {
                        ++wrote_extents;
+                       ++m_writeback_extent_count;
                        if (slowly) {
                                if (m_stop_requested) {
                                        slowly = false;
@@ -423,6 +435,12 @@ BeesHashTable::fetch_missing_extent_by_index(uint64_t extent_index)
                return;
        }
 
+       if (!m_persistent) {
+               // Memory-only mode: extent is zero-initialised from MAP_ANONYMOUS.
+               m_extent_metadata.at(extent_index).m_missing = false;
+               return;
+       }
+
        // OK we have to read this extent
        BEESNOTE("fetching hash extent #" << extent_index << " of " << m_extents << " extents");
        BEESTRACE("Fetching hash extent #" << extent_index << " of " << m_extents << " extents");
@@ -710,6 +728,9 @@ BeesHashTable::open_file()
 
        // If that doesn't work, try to make a new one
        if (!new_fd) {
+               if (!m_create) {
+                       THROW_ERRNO("hash table '" << m_filename << "' does not exist and state.hash.create = no");
+               }
                string tmp_filename = m_filename + ".tmp";
                BEESNOTE("creating new hash table '" << tmp_filename << "'");
                BEESLOGINFO("Creating new hash table '" << tmp_filename << "'");
@@ -732,17 +753,8 @@ BeesHashTable::open_file()
        m_fd = new_fd;
 }
 
-BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t size) :
-       m_ctx(ctx),
-       m_size(0),
-       m_void_ptr(nullptr),
-       m_void_ptr_end(nullptr),
-       m_buckets(0),
-       m_cells(0),
-       m_writeback_thread("hash_writeback"),
-       m_prefetch_thread("hash_prefetch"),
-       m_flush_rate_limit(BEES_FLUSH_RATE),
-       m_stats_file(m_ctx->home_fd(), "beesstats.txt")
+void
+BeesHashTable::init(string filename, off_t size)
 {
        // Sanity checks to protect the implementation from its weaknesses
        THROW_CHECK2(invalid_argument, BLOCK_SIZE_HASHTAB_BUCKET, BLOCK_SIZE_HASHTAB_EXTENT, (BLOCK_SIZE_HASHTAB_EXTENT % BLOCK_SIZE_HASHTAB_BUCKET) == 0);
@@ -754,8 +766,16 @@ BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t
        THROW_CHECK2(runtime_error, sizeof(Extent::p_byte), BLOCK_SIZE_HASHTAB_EXTENT, BLOCK_SIZE_HASHTAB_EXTENT == sizeof(Extent::p_byte));
 
        m_filename = filename;
+       const uint64_t desired_size = static_cast<uint64_t>(size);
        m_size = size;
-       open_file();
+       if (m_persistent) {
+               open_file();
+               if (m_resize && m_size != desired_size) {
+                       resize_file(static_cast<off_t>(desired_size));
+               } else if (m_size != desired_size) {
+                       BEESLOGWARN("hash table on-disk size " << pretty(m_size) << " differs from configured state.hash.size " << pretty(desired_size) << " (use state.hash.resize = yes to resize)");
+               }
+       }
 
        // Now we know size we can compute stuff
 
@@ -802,7 +822,11 @@ BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t
        }
 
        m_extent_metadata.resize(m_extents);
+}
 
+void
+BeesHashTable::start_threads()
+{
        m_writeback_thread.exec([&]() {
                writeback_loop();
         });
@@ -810,6 +834,28 @@ BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t
        m_prefetch_thread.exec([&]() {
                prefetch_loop();
         });
+}
+
+void
+BeesHashTable::flush()
+{
+       flush_dirty_extents(false);
+}
+
+BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t size) :
+       m_ctx(ctx),
+       m_size(0),
+       m_void_ptr(nullptr),
+       m_void_ptr_end(nullptr),
+       m_buckets(0),
+       m_cells(0),
+       m_writeback_thread("hash_writeback"),
+       m_prefetch_thread("hash_prefetch"),
+       m_flush_rate_limit(BEES_FLUSH_RATE),
+       m_stats_file(m_ctx->home_fd(), "beesstats.txt")
+{
+       init(filename, size);
+       start_threads();
 
        // Blacklist might fail if the hash table is not stored on a btrfs,
        // or if it's on a _different_ btrfs
@@ -828,6 +874,119 @@ BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t
        });
 }
 
+BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t size, NoThreadsTag) :
+       m_ctx(ctx),
+       m_size(0),
+       m_void_ptr(nullptr),
+       m_void_ptr_end(nullptr),
+       m_buckets(0),
+       m_cells(0),
+       m_writeback_thread("hash_writeback"),
+       m_prefetch_thread("hash_prefetch"),
+       m_flush_rate_limit(BEES_FLUSH_RATE),
+       m_stats_file(m_ctx->home_fd(), "beesstats.txt")
+{
+       init(filename, size);
+}
+
+BeesHashTable::BeesHashTable(shared_ptr<BeesContext> ctx, off_t size, NoFileTag) :
+       m_ctx(ctx),
+       m_size(0),
+       m_void_ptr(nullptr),
+       m_void_ptr_end(nullptr),
+       m_buckets(0),
+       m_cells(0),
+       m_writeback_thread("hash_writeback"),
+       m_prefetch_thread("hash_prefetch"),
+       m_flush_rate_limit(BEES_FLUSH_RATE),
+       m_stats_file(m_ctx->home_fd(), "beesstats.txt")
+{
+       m_persistent = false;
+       init("", size);
+}
+
+void
+BeesHashTable::set_flush_rate(double bps)
+{
+       m_flush_rate_limit.rate(bps);
+}
+
+void
+BeesHashTable::resize_file(off_t new_size)
+{
+       THROW_CHECK1(runtime_error, m_persistent, m_persistent);
+       if (m_size == static_cast<uint64_t>(new_size)) {
+               return;
+       }
+
+       const off_t old_size = static_cast<off_t>(m_size);
+       const uint64_t old_extents = static_cast<uint64_t>(old_size) / BLOCK_SIZE_HASHTAB_EXTENT;
+       const uint64_t new_buckets = static_cast<uint64_t>(new_size) / BLOCK_SIZE_HASHTAB_BUCKET;
+
+       BEESLOGINFO("Resizing hash table from " << pretty(old_size) << " (" << old_extents << " extents) to " << pretty(new_size));
+
+       // Allocate new table in RAM, zero-initialized.
+       vector<uint8_t> new_table(new_size, 0);
+       Bucket *const new_bucket_ptr = reinterpret_cast<Bucket *>(new_table.data());
+
+       // Insert one (hash, addr) cell into new_table using push-front LRU.
+       auto insert_cell = [&](HashType hash, AddrType addr) {
+               if (!addr) return;
+               Bucket &bucket = new_bucket_ptr[hash % new_buckets];
+               // If already present, don't duplicate.
+               for (uint64_t i = 0; i < c_cells_per_bucket; ++i) {
+                       if (bucket.p_cells[i].e_hash == hash && bucket.p_cells[i].e_addr == addr) {
+                               return;
+                       }
+               }
+               // Shift existing cells right, insert at front (evict the last).
+               for (uint64_t i = c_cells_per_bucket - 1; i > 0; --i) {
+                       bucket.p_cells[i] = bucket.p_cells[i - 1];
+               }
+               bucket.p_cells[0] = Cell(hash, addr);
+       };
+
+       // Read old extents in reverse order: oldest (lowest LRU priority) first,
+       // newest (highest LRU priority) last, so newest entries win on collision.
+       ByteVector extent_buf(BLOCK_SIZE_HASHTAB_EXTENT);
+       for (uint64_t pass = 0; pass < old_extents; ++pass) {
+               const uint64_t ei = old_extents - 1 - pass;
+               BEESNOTE("resizing hash table: reading extent " << (pass + 1) << " of " << old_extents);
+               const off_t offset = static_cast<off_t>(ei) * BLOCK_SIZE_HASHTAB_EXTENT;
+               pread_or_die(m_fd, extent_buf, offset);
+
+               const uint64_t buckets_per_extent = BLOCK_SIZE_HASHTAB_EXTENT / BLOCK_SIZE_HASHTAB_BUCKET;
+               const Bucket *old_buckets = reinterpret_cast<const Bucket *>(extent_buf.data());
+               for (uint64_t b = 0; b < buckets_per_extent; ++b) {
+                       for (uint64_t c = 0; c < c_cells_per_bucket; ++c) {
+                               insert_cell(old_buckets[b].p_cells[c].e_hash, old_buckets[b].p_cells[c].e_addr);
+                       }
+               }
+       }
+
+       // Write the new table to a temp file, fsync, rename over the original.
+       const string tmp_filename = m_filename + ".resize";
+       BEESNOTE("writing resized hash table to '" << tmp_filename << "'");
+       BEESLOGINFO("Writing resized hash table to '" << tmp_filename << "'");
+       unlinkat(m_ctx->home_fd(), tmp_filename.c_str(), 0);
+       Fd tmp_fd = openat_or_die(m_ctx->home_fd(), tmp_filename, FLAGS_CREATE_FILE, 0700);
+       ftruncate_or_die(tmp_fd, new_size);
+       const uint64_t new_extents = static_cast<uint64_t>(new_size) / BLOCK_SIZE_HASHTAB_EXTENT;
+       for (uint64_t ei = 0; ei < new_extents; ++ei) {
+               BEESNOTE("writing resized hash table: extent " << (ei + 1) << " of " << new_extents);
+               const off_t write_offset = static_cast<off_t>(ei) * BLOCK_SIZE_HASHTAB_EXTENT;
+               pwrite_or_die(tmp_fd, new_table.data() + write_offset, BLOCK_SIZE_HASHTAB_EXTENT, write_offset);
+       }
+       bees_fsync(tmp_fd);
+       tmp_fd = Fd();
+       renameat_or_die(m_ctx->home_fd(), tmp_filename, m_ctx->home_fd(), m_filename);
+
+       // Reopen the renamed file and update m_size.
+       m_fd = openat_or_die(m_ctx->home_fd(), m_filename, FLAGS_OPEN_FILE_RW, 0700);
+       m_size = new_size;
+       BEESLOGINFO("Hash table resize complete: " << pretty(old_size) << " -> " << pretty(new_size));
+}
+
 BeesHashTable::~BeesHashTable()
 {
        BEESLOGDEBUG("Destroy BeesHashTable");
@@ -875,5 +1034,11 @@ BeesHashTable::stop_wait()
        BEESLOGDEBUG("Waiting for hash_writeback thread");
        m_writeback_thread.join();
 
+       if (m_persistent && m_close_fsync && m_fd) {
+               BEESNOTE("fsync hash table on close");
+               BEESLOGDEBUG("fsync hash table on close");
+               catch_all([&]() { bees_fsync(m_fd); });
+       }
+
        BEESLOGDEBUG("BeesHashTable stopped");
 }
index ee9dd19d7e106aa4243b5d3217edec17035f400e..b766b837d35d8eac5613178ab067fedf5b00876b 100644 (file)
@@ -1515,6 +1515,28 @@ BeesRoots::set_poll_bounds(double min_seconds, double max_seconds)
        BEESLOGINFO("poll-max = " << max_seconds << "s [loop.poll-max]");
 }
 
+void
+BeesRoots::set_checkpoint_interval(double seconds)
+{
+       THROW_CHECK1(invalid_argument, seconds, seconds >= 0);
+       m_checkpoint_interval = seconds;
+       BEESLOGINFO("checkpoint interval = " << m_checkpoint_interval << "s [state.point.interval]");
+}
+
+void
+BeesRoots::set_persistent(bool v)
+{
+       BEESLOGINFO("state persistence = " << (v ? "yes" : "no") << " [state.persistent]");
+       m_roots_persistent = v;
+}
+
+void
+BeesRoots::set_checkpoint_defer(bool v)
+{
+       BEESLOGINFO("state defer = " << (v ? "yes" : "no") << " [state.point.defer]");
+       m_checkpoint_defer = v;
+}
+
 uint64_t
 BeesRoots::effective_transid_max()
 {
index c811bd9391e116bd1340a07633691c5ee06c59f8..3e689974c1d4535ceab4940ffef7a0bd068f5afc 100644 (file)
@@ -542,6 +542,44 @@ bees_main(int argc, char *argv[])
                bc->set_reporter(reporter);
        }
 
+       // Set up persistent state (hash table and crawl checkpoints)
+       {
+               const bool persistent = bc->get_config().get("state.persistent", bees_parse_bool);
+               // Round up to the next hash table extent boundary (128 KiB).
+               const auto hash_size_raw = static_cast<off_t>(bc->get_config().get("state.hash.size", [&](const string &s) {
+                       return bees_parse_size(bc->get_config().subst(s));
+               }));
+               const auto hash_size = (hash_size_raw + BLOCK_SIZE_HASHTAB_EXTENT - 1)
+                                      / BLOCK_SIZE_HASHTAB_EXTENT * BLOCK_SIZE_HASHTAB_EXTENT;
+
+               if (!persistent) {
+                       // Pure in-memory mode: allocate hash table in RAM, skip all file I/O.
+                       bc->init_hash_table_memory(hash_size);
+                       bc->roots()->set_persistent(false);
+               } else {
+                       // File-backed mode: open (or create) beeshash.dat.
+                       bc->init_hash_table("beeshash.dat", hash_size);
+                       auto ht = bc->hash_table();
+                       ht->set_create(bc->get_config().get("state.hash.create", bees_parse_bool));
+                       ht->set_resize(bc->get_config().get("state.hash.resize", bees_parse_bool));
+                       ht->set_writeback_fsync(bc->get_config().get("state.hash.writeback-fsync", bees_parse_bool));
+                       ht->set_writeback_unreadahead(bc->get_config().get("state.hash.writeback-unreadahead", bees_parse_bool));
+                       ht->set_close_fsync(bc->get_config().get("state.hash.close-fsync", bees_parse_bool));
+                       // Compute effective writeback rate: min(size / writeback-time, writeback-rate-max)
+                       const double writeback_time = bc->get_config().get("state.hash.writeback-time", bees_parse_duration);
+                       const double writeback_rate_max = static_cast<double>(bc->get_config().get("state.hash.writeback-rate-max", bees_parse_size));
+                       const double computed_rate = writeback_time > 0 ? static_cast<double>(hash_size) / writeback_time : writeback_rate_max;
+                       ht->set_flush_rate(min(computed_rate, writeback_rate_max));
+               }
+               bc->start_hash_writeback();
+
+               // Crawl checkpoint options
+               bc->roots()->set_checkpoint_interval(
+                       bc->get_config().get("state.point.interval", bees_parse_duration));
+               bc->roots()->set_checkpoint_defer(
+                       bc->get_config().get("state.point.defer", bees_parse_bool));
+       }
+
        // Workaround for the logical-ino-vs-clone kernel bug
        MultiLocker::enable_locking(true);
 
index 3eb5a01451b5a84adb977ea1a77789b2453c2f5f..b6e49a84324872fe78bfc5a7a6e08e63bd52fb0d 100644 (file)
@@ -26,6 +26,7 @@
 #include "crucible/task.h"
 
 #include <array>
+#include <atomic>
 #include <functional>
 #include <list>
 #include <mutex>
@@ -734,12 +735,23 @@ public:
 
        /// Open or create the hash table file at @p filename with the given @p size.
        BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t size = BLOCK_SIZE_HASHTAB_EXTENT);
+
+       /// Tag type: pass as fourth argument to construct without starting threads.
+       struct NoThreadsTag {};
+       /// Like BeesHashTable(ctx, filename, size) but does not start background threads.
+       /// Call start_threads() later to start writeback and prefetch threads.
+       BeesHashTable(shared_ptr<BeesContext> ctx, string filename, off_t size, NoThreadsTag);
+
        ~BeesHashTable();
 
        /// Signal background threads to stop.
        void stop_request();
        /// Block until background threads have stopped.
        void stop_wait();
+       /// Start writeback and prefetch background threads (call once after construction).
+       void start_threads();
+       /// Flush all dirty extents to disk synchronously (no rate limiting).
+       void flush();
 
        /// Return all Cells whose hash matches @p hash.
        vector<Cell>    find_cell(HashType hash);
@@ -752,6 +764,34 @@ public:
        /// Write the extent at @p extent_index to disk if dirty; return true if written.
        bool            flush_dirty_extent(uint64_t extent_index);
 
+       /// Tag type: pass as third argument to construct a memory-only table (no file I/O).
+       struct NoFileTag {};
+       /// Like BeesHashTable(ctx, filename, size, NoThreadsTag) but with no file backing.
+       /// The table lives entirely in RAM; all writeback file options are irrelevant.
+       BeesHashTable(shared_ptr<BeesContext> ctx, off_t size, NoFileTag);
+
+       /// Total number of extents in the hash table.
+       uint64_t extent_count() const { return m_extents; }
+       /// Running total of extents confirmed consistent with on-disk state since startup.
+       /// Incremented for each written extent and for each clean (skipped) extent.
+       uint64_t writeback_extent_count() const {
+               return m_writeback_extent_count.load(memory_order_relaxed);
+       }
+       /// If false, table lives in RAM only; all file options are ignored (call before start_threads()).
+       void set_persistent(bool v) { m_persistent = v; }
+       /// Set writeback rate in bytes per second.
+       void set_flush_rate(double bps);
+       /// If false, throw at startup when beeshash.dat is absent instead of creating it.
+       void set_create(bool v) { m_create = v; }
+       /// If true, rebuild the on-disk table at state.hash.size on startup (reverse-LRU merge).
+       void set_resize(bool v) { m_resize = v; }
+       /// fsync after each extent write.
+       void set_writeback_fsync(bool v) { m_writeback_fsync = v; }
+       /// Discard hash table pages from VFS page cache after each write.
+       void set_writeback_unreadahead(bool v) { m_writeback_unreadahead = v; }
+       /// fsync the hash table on close (at bees termination only).
+       void set_close_fsync(bool v) { m_close_fsync = v; }
+
 private:
        string          m_filename;
        Fd              m_fd;
@@ -791,6 +831,15 @@ private:
        condition_variable      m_stop_condvar;
        bool                    m_stop_requested = false;
 
+       // Options from [state.hash.*] config
+       atomic<uint64_t>        m_writeback_extent_count{0};
+       bool                    m_persistent = true;
+       bool                    m_create = true;
+       bool                    m_resize = false;
+       bool                    m_writeback_fsync = false;
+       bool                    m_writeback_unreadahead = false;
+       bool                    m_close_fsync = false;
+
        /// Per-extent metadata tracked in memory alongside the mmap'd data.
        struct ExtentMetaData {
                shared_ptr<mutex> m_mutex_ptr;  ///< Serializes concurrent access to this extent.
@@ -800,6 +849,7 @@ private:
        };
        vector<ExtentMetaData>  m_extent_metadata;
 
+       void init(string filename, off_t size);
        void open_file();
        void writeback_loop();
        void prefetch_loop();
@@ -810,6 +860,7 @@ private:
        void fetch_missing_extent_by_index(uint64_t extent_index);
        void set_extent_dirty_locked(uint64_t extent_index);
        size_t flush_dirty_extents(bool slowly);
+       void resize_file(off_t new_size);
 
        size_t                  hash_to_extent_index(HashType ht);
        unique_lock<mutex>      lock_extent_by_hash(HashType ht);
@@ -952,6 +1003,9 @@ class BeesRoots : public enable_shared_from_this<BeesRoots> {
        bool                                    m_exit_one_pass = false;       ///< Exit after all crawlers complete one pass.
        uint64_t                                m_min_transid_age = 0;   ///< Stay this many transids behind transid_max().
        uint64_t                                m_min_transid_span = 0;  ///< Minimum transid window before starting a new pass (0=any).
+       double                          m_checkpoint_interval = BEES_WRITEBACK_INTERVAL;  ///< Seconds between checkpoint writes.
+       bool                            m_roots_persistent = true;      ///< If false, skip load/save of beespoint.ini.
+       bool                            m_checkpoint_defer = false;     ///< If true, defer writes until hash writeback catches up.
 
        vector<shared_ptr<BeesScanMode>>        m_scanners;  ///< Active scan-mode strategy objects.
 
@@ -1048,6 +1102,12 @@ public:
        void set_min_transid_span(uint64_t span);
        /// Set poll interval bounds in seconds for the transid poll loop.
        void set_poll_bounds(double min_seconds, double max_seconds);
+       /// Set the interval in seconds between crawl checkpoint writes.
+       void set_checkpoint_interval(double seconds);
+       /// Disable beespoint.ini persistence (skip load and save).
+       void set_persistent(bool v);
+       /// Defer checkpoint writes until the hash table writeback has lapped past the snapshot point.
+       void set_checkpoint_defer(bool v);
 
        /// Return the lowest transaction ID seen across all active crawls.
        uint64_t transid_min();
@@ -1294,6 +1354,19 @@ public:
        /// Return a reference to the parsed configuration.
        const BeesConfig &get_config() const;
 
+       /// Discrete-init API.  These are hooks that split
+       /// BeesContext::start() / stop() into pieces to enable future
+       /// extensions like hash table resize and non-persistent hash tables.
+       void init_hash_table(const string &filename, off_t size);
+       void init_hash_table_memory(off_t size);
+       void start_hash_writeback();
+       void flush_hash_table();
+       void load_state();
+       void save_state();
+       void print_state(ostream &os);
+       void start_crawl();
+       void stop_crawl();
+
        /// Return the FD to the btrfs root mount point.
        Fd root_fd() const { return m_root_fd; }
        /// Return the directory fd used for O_TMPFILE creation (from tempfile.dir).