]> git.hungrycats.org Git - bees/commitdiff
filter: count verdicts and add the [log.filter] log category
authorZygo Blaxell <bees@furryterror.org>
Sun, 6 Sep 2026 01:46:35 +0000 (21:46 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sun, 6 Sep 2026 01:46:35 +0000 (21:46 -0400)
There was no way to tell whether a configured filter rule ever decided
anything: the only trace of chain evaluation was buried in the planner
log at its highest level, which produces gigabytes of unrelated output.

Account for every chain evaluation at the memoized call site in
BeesFilterCache, which runs the chain once per extent per dedupe role
per planner pass.  Each evaluation bumps filter_<role>_<verdict> and
filter_<role>_<verdict>_<rule>, where rule is the name of the rule that
ended the chain or the ACCEPT/REJECT terminal token when nothing did.
The counters cost a map lookup per evaluation and answer the common
question ("is my REJECT rule matching anything?") from the stats file.

Add a `filter` log category.  INFO emits one line per evaluation with
the extent, role, verdict, deciding rule and chain spec; DEBUG adds the
existing condition-by-condition trace, built only when that level is
enabled.

Assisted-by: Claude-Code:claude-fable-5-1
docs/config-file.md
docs/event-counters.md
src/bees-config-v1.cc
src/bees-context.cc
src/bees-filter-cache.h
src/bees-log.cc
src/bees-log.h

index 43bd194690c96f0835695af4ffa8394c7fd1fb8d..45c6ebc364cbe75a0bb38153d31be4374c0fee34 100644 (file)
@@ -543,6 +543,7 @@ deployments.
 | `[log.transid]`  | transaction lag, commit boundaries, idle scheduling                 |
 | `[log.report]`   | periodic stats, status output, reporters                            |
 | `[log.hash]`     | hash table LRU eviction, fill rate, persistence                     |
+| `[log.filter]`   | filter chain verdicts per extent, condition-by-condition traces     |
 
 All categories default to `[log.*] level = 5` (warnings-and-errors
 only).  Common opt-ins:
@@ -552,6 +553,14 @@ only).  Common opt-ins:
   expected duplicates are being deduplicated.
 * `[log.plan] level = 7` — see candidate-by-candidate planner decisions.
   Useful for diagnosing why an expected dedupe didn't happen.
+* `[log.filter] level = 7` — one line per filter chain evaluation naming
+  the extent, the dedupe role (`src` or `dst`), the verdict, and the rule
+  that decided it.  Useful for confirming that a new filter rule matches
+  what you meant it to.  `level = 8` adds the condition-by-condition
+  trace for every evaluation, which is several lines per extent.  Both
+  are high volume: the chain runs once per extent per role.  The
+  `filter_*` [event counters](event-counters.md#filter) give the same
+  information in aggregate at no cost.
 
 ### Example
 
index de56c5d5f8f0813d3c4aaf9e6f5cbfe1ae97a229..6f8cbcb589596fbc0191d9c97f2074ed3e1e731f 100644 (file)
@@ -206,6 +206,22 @@ The `extent` event group consists of events that occur within the extent scanner
  * `extent_zero`: An ioctl call to `LOGICAL_INO` succeeded, but reported an empty list of extents.
  * `extent_zero_ref`: A start extent had zero references (extent freed or all refs stale) immediately after it was locked in the planner.
 
+filter
+------
+
+The `filter` event group counts filter chain verdicts.  A chain is evaluated
+once per extent per dedupe role (`src` or `dst`) per planner pass, so these
+counters show whether a configured filter rule is deciding anything.  Names
+are built from the role, the verdict, and the name of the rule that ended
+the chain, so the set of counters depends on the configuration:
+
+ * `filter_src_accept`, `filter_src_reject`, `filter_dst_accept`, `filter_dst_reject`: Total verdicts by role.
+ * `filter_<role>_<verdict>_<rule>`: The same verdicts split by the deciding rule, e.g. `filter_dst_reject_common.datacow` is one NODATACOW extent rejected as a dedupe destination by the built-in `common.datacow` rule.  A rule whose `match-action` is `NEXT` never decides a verdict and so never appears here.
+ * `filter_<role>_<verdict>_ACCEPT`, `filter_<role>_<verdict>_REJECT`: Extents that fell through every rule to the chain's terminal token, e.g. `filter_src_accept_ACCEPT` is an extent no rule rejected.
+
+Per-extent detail is available from the `[log.filter]` log category (see
+[bees Configuration File Reference](config-file.md#categories)).
+
 hash
 ----
 
index a17cbc3a5eccf343bea6ea8ebf16e9a97010b151..dd14a164953cfe7099cb5de8ada434c7db315f85 100644 (file)
@@ -95,7 +95,7 @@ static const char bees_config_v1[] = R"--v1-config--(
 # The [log.*] section sets the default level for every per-category log
 # slot.  Each named [log.<cat>] subsection can override its own level;
 # unspecified categories inherit from [log.*].  Categories: plan, exec,
-# sys, scan, transid, report, hash.
+# sys, scan, transid, report, hash, filter.
 #
 # Categorized logging is opt-in: by default every category is gated to
 # 5 = LOG_NOTICE-threshold (warnings, errors, and more critical only;
index fcbff5a4b52aa188b2755b6c578c473b14ddecc1..c6b4ff0e57b3ec62153f1f586bb47fe751b3446b 100644 (file)
@@ -1451,6 +1451,7 @@ BeesContext::set_config(const Innie& bconfig)
                "transid",
                "report",
                "hash",
+               "filter",
        };
 
        BeesLogConfig log_cfg;
index b758b79c04a2c818714a7b156a3b76b4da1995d2..afb21437c0cae40defc14f2ccb5016f7bd3dc0bd 100644 (file)
 /// A null filter chain is accepted and treated as "always accept",
 /// which keeps test paths and unconfigured filter setups simple.
 
+#include "bees.h"
 #include "bees-extent.h"
 #include "bees-filter.h"
 
 #include <cstdint>
 #include <memory>
 #include <optional>
+#include <sstream>
+#include <string>
 #include <unordered_map>
 
 class BeesFilterCache {
@@ -42,7 +45,7 @@ public:
                        bool accept = true;
                        if (m_chain) {
                                BeesFilterState state;
-                               m_chain->evaluate_extent(extent, state, true, false);
+                               evaluate(extent, state, true, false);
                                accept = state.accept;
                        }
                        result.m_accept_src = accept;
@@ -61,7 +64,7 @@ public:
                        bool force = false;
                        if (m_chain) {
                                BeesFilterState state;
-                               m_chain->evaluate_extent(extent, state, false, true);
+                               evaluate(extent, state, false, true);
                                accept = state.accept;
                                force = accept && state.force_rewrite;
                        }
@@ -95,6 +98,33 @@ private:
                bool m_force_rewrite = false;
        };
 
+       /// Run the chain once for one role, then account for the verdict:
+       /// `filter_<role>_<verdict>` and `filter_<role>_<verdict>_<rule>`
+       /// event counters (rule = the deciding rule's name, or the ACCEPT /
+       /// REJECT terminal that ended the chain), one INFO line per
+       /// evaluation in the filter log category, and the condition-by-
+       /// condition trace at DEBUG.  Memoization in the callers means this
+       /// runs once per (extent, role) per planner pass.
+       void evaluate(BeesExtent &extent, BeesFilterState &state, bool is_src, bool is_dst)
+       {
+               if (bees_log_cat_level[BeesLogCat::Filter] > LOG_DEBUG) {
+                       std::ostringstream trace;
+                       m_chain->evaluate_extent(extent, state, is_src, is_dst, trace);
+                       BEESLOGC(DEBUG, Filter, trace.str());
+               } else {
+                       m_chain->evaluate_extent(extent, state, is_src, is_dst);
+               }
+               const std::string role    = is_src ? "src" : "dst";
+               const std::string verdict = state.accept ? "accept" : "reject";
+               const std::string rule    = state.matched_rule ? state.matched_rule->name : "none";
+               const std::string counter = "filter_" + role + "_" + verdict;
+               BeesStats::s_global.add_count(counter);
+               BeesStats::s_global.add_count(counter + "_" + rule);
+               BEESLOGC(INFO, Filter, "filter " << role << " " << to_hex(extent.bytenr())
+                       << " " << (state.accept ? "ACCEPT" : "REJECT")
+                       << " by " << rule << " in chain '" << m_chain->spec << "'");
+       }
+
        std::shared_ptr<BeesFilterChain> m_chain;
        std::unordered_map<uint64_t, Entry> m_map;
 };
index 3c6d470fcf3920b70999be4a01a3055825608ae0..62caf75615c15600bb6ab5f37d27b0eec00d5e8b 100644 (file)
@@ -21,8 +21,9 @@ int bees_log_cat_level[BeesLogCat::Count_] = {
        8,  // BeesLogCat::Transid
        8,  // BeesLogCat::Report
        8,  // BeesLogCat::Hash
+       8,  // BeesLogCat::Filter
 };
-static_assert(BeesLogCat::Count_ == 8, "update bees_log_cat_level defaults");
+static_assert(BeesLogCat::Count_ == 9, "update bees_log_cat_level defaults");
 
 namespace {
 
index af51b955fd9f71f824eb755b0302a2d0eee79171..41ba9ed28565dddd332b93ef19ebd58e7797a209 100644 (file)
@@ -41,6 +41,8 @@ struct BeesLogCat {
                Report,
                /// Hash table LRU eviction, fill rate, persistence.
                Hash,
+               /// Filter chain evaluation results and per-condition traces.
+               Filter,
                /// Sentinel: number of categories.  Not a real slot.
                Count_,
        };