diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2113337d4c..77f67a500da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: hooks: - id: check-added-large-files # Retain the exact lossless cross-platform performance evidence archives. - exclude: ^docs/benchmarks/((executor-copy-audit|bounded-read-ahead)-20261101|(directory-task-slots|packed-visit-snapshots)-20261102)\.json\.gz$ + exclude: ^docs/benchmarks/((executor-copy-audit|bounded-read-ahead)-20261101|(directory-task-slots|packed-visit-snapshots|bounded-match-results)-20261102)\.json\.gz$ - id: check-merge-conflict - id: check-yaml # BINARY_TYPES below: archive containers are binary but can START with text (a phar begins with diff --git a/CHANGELOG.md b/CHANGELOG.md index a7852c72d55..a66fa0a7fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ # 0.11.0 +- Reuse bounded matcher output buffers between batches while clearing stale selection/error state and releasing oversized old output. + - Pack matcher and deferred entry text into one owned buffer while preserving moves, output order and filesystem lifetimes. - Reuse typed directory-task records and publish completion without the shared queue lock. diff --git a/TODO.md b/TODO.md index 532ef81987d..6cfc4717c79 100644 --- a/TODO.md +++ b/TODO.md @@ -399,8 +399,12 @@ EP/JC/P work; candidates are not speedup claims until measured on both platforms vector relocation, opaque bytes, and archive ownership. Preserve allocation counts, modest gains and slower controls in [the two-host report](docs/benchmarks/packed-visit-snapshots-20261102.md). Keep the forced out-of-line constructor experimental; no batch-recycling API or worker change. -- [ ] O10-O12: Profile and qualify bounded result reuse, active-worker dispatch and direct - production evaluation. +- [x] O10: Qualify bounded matcher result-text reuse on macOS and Linux, including old matched + slots becoming unmatched, status/text reset, total/per-record reuse limits and giant tails. + [Full evidence and rejected controls](docs/benchmarks/bounded-match-results-20261102.md) + separate allocation savings from unrelated timing changes. Comparison payload reuse remains + a source-API question; retaining its result vector alone is rejected. +- [ ] O11-O12: Profile and qualify active-worker dispatch and direct production evaluation. Preserve existing stage policy while isolating task storage and lifecycle costs. - [ ] O13-O22: Reconcile remaining derived-value handoff, static catalogs, invariant operands, paired streaming compare, reusable stage pools, byte-bounded overlap, archive indexing/admission, diff --git a/docs/benchmarks/bounded-match-results-20261102.json.gz b/docs/benchmarks/bounded-match-results-20261102.json.gz new file mode 100644 index 00000000000..04ae1bcd3c7 Binary files /dev/null and b/docs/benchmarks/bounded-match-results-20261102.json.gz differ diff --git a/docs/benchmarks/bounded-match-results-20261102.md b/docs/benchmarks/bounded-match-results-20261102.md new file mode 100644 index 00000000000..9eaae09ad5b --- /dev/null +++ b/docs/benchmarks/bounded-match-results-20261102.md @@ -0,0 +1,86 @@ +# Bounded matcher result reuse (2026-11-02) + +## Decision + +Retain bounded text-capacity reuse in the existing matcher result slots. Each worker writes only its exclusively claimed slot. Resize the result population without discarding every previous record, release oversized old output before starting new evaluation, clear reused text, reset selection status, and disengage content when a retained matched slot becomes unmatched. Keep generation barriers, input ownership, ordered consumption, worker counts and admission policy. + +Reusable text capacities are limited to `min(64 KiB, 1 MiB / current entries)` per slot. This bounds text capacity carried from the consumed batch, not newly rendered output, status-message storage, vector metadata or total process memory. Current output can exceed the reuse limit and remains available until the next call or destruction. Shrinking/empty batches destroy their old tails. No output is truncated. The pool owns returned records; callers observe them until the next batch. + +Reject the direct-write-only reset variant as a separate change: it saves no output allocations. Reject comparison-vector retention: range reads still create fresh owning strings, so retaining old result slots does not reuse their VFS input buffers and can prolong old small patch payloads. Genuine comparison payload reuse needs a separate budget-aware source design. Do not revive slower generic copying wrappers or bypass the VFS. + +## Measurement and source contract + +The exact measured parent is `8d96bc90fea5adb469b42e5e34a8be6a518f0f4b` (#1049). Merged main `901c8fea916124f4d0e195c5385f2a977b44093d` has the same tree; preserve the measured identity after synchronization. Clean LLVM 23.1.4 `clang_release`/ThinLTO binaries are built before timing. All source variants, binary hashes, flags, fixtures, raw samples and scripts are retained below. Test-only BUILD dependencies and final regression/wording fixes do not change the timed runtime mechanism. + +Variants are baseline, `in_place_reset` (direct result-slot writes while clearing/recreating records), `bounded_match`, and `compare_vector` (only removes the comparison vector clear). Keep the latter two controls separate from the selected matcher change. Copied benchmark binaries link only stable system libraries; no mutable project shared library supplies a different variant at runtime. Unrelated timing changes of the comparison-only control remain unexplained and are not credited to matcher buffer reuse. + +Each host has 61 native cases and three sessions, one discarded warmup per variant/case/session, nine rotating participant rounds and fastest-seven means. The combined population contains 14,640 fresh invocations including warmups. Rotation has no parity reversal. Every invocation validates an independent output oracle: sorted paths, sizes, complete collection/comparison records, or explicit `path:x` line-output digests. Linux uses tmpfs with 1/3/10-CPU affinity; macOS uses warm APFS without affinity. Fixtures span 10, 1,000, 10,000 and broad/deep 4,096 files, plus 65,536-file collection controls and small/large two-root comparisons. + +In-memory kernels use 46 RE2 cells and 16 comparison cells, three sessions, nine Google Benchmark repetitions/fastest seven, with a separate process per case/participant and rotating participant order between cases. Linux pins 1/4 CPUs; macOS uses worker allowances without affinity. The eight-case targeted confirmation uses the same source binaries and a separate population. Builds, lint and sanitizers never overlap timing on a host. Kernel validation checks expected outputs once; it is not the native per-invocation oracle. + +## Native selected results + +Changes are medians of paired session ratios; negative means faster. All cells and slower samples remain in the archive. + +| Fixture / task | Workers | Linux change | macOS change | +| :-------------------------- | ------: | -----------: | -----------: | +| flat10 / rg-lines | 1 | +0.41% | +0.54% | +| flat1000 / rg-filter | 3 | +0.19% | -0.84% | +| flat10000 / rg-lines | 1 | -0.24% | +0.32% | +| flat10000 / native-lines | 3 | -3.17% | -0.36% | +| nested8x8x64 / native-lines | 3 | -3.42% | +0.38% | +| flat1000 / native-lines | 10 | +2.69% | +0.24% | +| flat10000 / metadata | 10 | +1.06% | +1.63% | +| compare-small / compare | 3 | -1.46% | +0.01% | +| compare-large / compare | 3 | -1.35% | -0.16% | + +The native results are modest. The largest selected native medians are +2.69% in a Linux output control and +1.63% in a macOS metadata control; do not claim every scan is faster. Three-session medians do not eliminate scheduling or code-layout sensitivity. + +## Kernel confirmation and negative controls + +The original selected macOS ten-file large-output cell is +4.90%, with session values -0.49/+13.84/+4.90%; its focused confirmation is -1.42%. Preserve both. The original Linux small early-difference comparison control is +6.67% and varies sharply; the focused change also occurs in the unrelated comparison-only control. Do not explain unrelated benchmark shifts as saved matcher allocations. + +| Kernel | Linux direct/reset | Linux bounded | Linux compare control | macOS direct/reset | macOS bounded | macOS compare control | +| :------------------------- | -----------------: | ------------: | --------------------: | -----------------: | ------------: | --------------------: | +| RE2/filter/1000/4 | +0.40% | -1.09% | +2.70% | +2.03% | +1.66% | +0.26% | +| RE2/large-output/10/1 | -0.78% | -0.39% | -0.71% | -0.56% | -1.42% | -0.56% | +| RE2/large-output/1000/4 | -4.12% | -3.24% | -2.72% | +1.67% | -1.32% | +0.62% | +| RE2/native-lines/1000/1 | +1.16% | -0.72% | +0.88% | +0.63% | -0.63% | +0.21% | +| RE2/rg-files/10/4 | -1.22% | -0.41% | -1.38% | +0.31% | +0.37% | -0.73% | +| RE2/rg-files/1000/1 | -5.73% | -8.39% | -9.05% | +0.26% | -0.09% | +0.80% | +| RE2/rg-files/10000/1 | -5.03% | -8.67% | -8.83% | +1.61% | -2.95% | -0.16% | +| large-early-different/10/4 | +1.14% | -12.07% | -11.94% | -1.58% | -0.52% | -0.49% | + +The confirmed macOS 1,000-file/four-worker filter control remains +1.66%; native small/metadata costs and the full initial kernel population are retained. These costs accompany a measured allocation reduction, not a universal throughput improvement. + +## Isolated allocation traffic + +One coordinator processes a warmed 64-entry output batch twice. Each entry produces a 1,028-byte line. Inputs, fixture construction, warmup and assertions are outside recording. ASan hooks count only coordinator allocation traffic, not release elapsed time, net live memory or allocator-resident bytes. The second batch repeats the same counts. The unrelated packed-snapshot diagnostic remains a negative allocation control. + +| Host / variant | Allocation calls | Requested bytes | +| :--------------------- | ---------------: | --------------: | +| macos / baseline | 320 | 264,704 | +| macos / in_place_reset | 320 | 264,704 | +| macos / bounded_match | 256 | 198,656 | +| macos / compare_vector | 320 | 264,704 | +| linux / baseline | 320 | 266,752 | +| linux / in_place_reset | 320 | 266,752 | +| linux / bounded_match | 256 | 200,192 | +| linux / compare_vector | 320 | 266,752 | + +Only bounded reuse changes those counts: 320 -> 256 calls (20% fewer) and about 25% fewer requested bytes. The reset-only and comparison-only controls remain at baseline allocation traffic. + +## Footprint and validation + +- macos: unstripped build output 4,051,440 -> 4,051,424 bytes (-16). +- linux: unstripped build output 12,294,864 -> 12,292,536 bytes (-2,328). + +Both complete core/extension release suites pass 306/306; Linux requires its working FUSE integration. Focused ASan and Linux TSan cover real worker output and repeated states. Header-aware scoped clang-tidy covers five translation units; the final test-only arithmetic finding is corrected and its unit rechecked. Normal commit/push hooks and current pushed-head CI remain required before merge. + +Tests exercise reuse below the limit, aggregate/per-record capacity limits, large output followed by a short inline tail, a retained matched slot becoming unmatched, an empty batch followed by growth, and false/error/true selection transitions with no stale output. Existing ordering, source ownership, archive admission, safety and deferred-evaluation tests remain in the full suites. + +Native wall/user/system CPU and child peak RSS are retained per sample. No first-output-latency or total-live-memory improvement is claimed. Persistent worker participation and comparison source-buffer reuse remain separate candidates. + +## Lossless evidence + +[bounded-match-results-20261102.json.gz](bounded-match-results-20261102.json.gz): 1,606,108 bytes; SHA-256 `8a4b4bfbdb8ecff5904597325ab570cad2650d5b13007d177746b762b563ae8f`. Restore native fixtures from `fixtures[report.fixtures_ref]`. Initial, targeted, allocation and rejected-control populations keep their separate provenance. diff --git a/docs/performance-analysis.md b/docs/performance-analysis.md index a7ea737efd8..c6f9b8df68c 100644 --- a/docs/performance-analysis.md +++ b/docs/performance-analysis.md @@ -3268,3 +3268,27 @@ the corrected representative native confirmation rotates participants without th published benchmark runner already uses rotation alone and is unaffected. Audit older two-variant qualification scripts before treating tiny historical differences as order-independent evidence. Allocation counters and correctness tests do not depend on participant timing order. + +## O10: bounded matcher result capacity reuse + +The selected matcher pool keeps reusable output strings in its existing exclusively claimed result +slots. It releases oversized old buffers before starting another batch and resets text, selection +status and absent-content state per entry. Reusable text capacities are limited to the smaller of +64 KiB per record and 1 MiB divided by the current batch size. This is a retention policy between +batches, not a limit on current rendered output, status-message storage or total memory. + +[The decision report](benchmarks/bounded-match-results-20261102.md) preserves native timing, +case-interleaved kernels, targeted noisy controls and allocation counters on both hosts. A warmed +64-entry output batch reduces coordinator allocations from 320 to 256 and requested bytes by about +25%. macOS/Linux elapsed gains are modest and not universal; the report retains slower cells. + +Reject the direct-write/reset-only control as a separate optimization because it does not save +output allocations. Reject comparison-vector retention: VFS range reads still return fresh owned +strings, and retaining old result slots can prolong old small patch payloads without reusing input +buffers. Its unrelated matcher timing changes are not evidence of saved matcher allocations; their +cause is not established. Release binaries were checked for mutable project shared-library inputs. + +Generation barriers, worker admission/counts, source ownership and ordered consumption remain +unchanged. Tests cover retained matched slots becoming unmatched, false/error/true transitions, +both capacity limits, oversized output followed by a short inline tail, empty batches and growth. +Keep active-worker participation (O11) and budget-aware comparison source reuse separate. diff --git a/release-site.json b/release-site.json index d0a7270761e..b9c5a7c8762 100644 --- a/release-site.json +++ b/release-site.json @@ -67,7 +67,8 @@ "docs/benchmarks/host-range-overwrite-20261102.md": "docs/benchmarks/host-range-overwrite-20261102.html", "docs/benchmarks/cursor-owned-read-buffers-20261102.md": "docs/benchmarks/cursor-owned-read-buffers-20261102.html", "docs/benchmarks/directory-task-slots-20261102.md": "docs/benchmarks/directory-task-slots-20261102.html", - "docs/benchmarks/packed-visit-snapshots-20261102.md": "docs/benchmarks/packed-visit-snapshots-20261102.html" + "docs/benchmarks/packed-visit-snapshots-20261102.md": "docs/benchmarks/packed-visit-snapshots-20261102.html", + "docs/benchmarks/bounded-match-results-20261102.md": "docs/benchmarks/bounded-match-results-20261102.html" }, "generated_html": { "XFF.md": { diff --git a/xff/engine/BUILD.bazel b/xff/engine/BUILD.bazel index 68ab2f350ed..1cac4a8bdcc 100644 --- a/xff/engine/BUILD.bazel +++ b/xff/engine/BUILD.bazel @@ -688,6 +688,7 @@ cc_test( size = "small", srcs = ["rg_test.cc"], deps = [ + ":evaluate_cc", ":owned_visit_cc", ":parallel_match_cc", ":run_cc", @@ -990,6 +991,7 @@ cc_test( ":expression_qualification_cc", ":owned_visit_cc", ":parallel_compare_cc", + ":parallel_match_cc", ":walk_cc", "//xff/fuzzy:fuzzy_cc", "//xff/parser:parser_cc", diff --git a/xff/engine/expression_allocations_test.cc b/xff/engine/expression_allocations_test.cc index f98c459b329..3ffade081c9 100644 --- a/xff/engine/expression_allocations_test.cc +++ b/xff/engine/expression_allocations_test.cc @@ -28,6 +28,7 @@ #include "xff/engine/expression_qualification.h" #include "xff/engine/owned_visit.h" #include "xff/engine/parallel_compare.h" +#include "xff/engine/parallel_match.h" #include "xff/engine/walk.h" #include "xff/fuzzy/fuzzy.h" #include "xff/parser/parser.h" @@ -213,6 +214,14 @@ class DirectoryAllocationFs final : public AllocationFs { std::size_t directories_; }; +class OutputAllocationFs final : public AllocationFs { + public: + absl::StatusOr ReadContent(std::string_view) const override { return text_; } + + private: + const std::string text_ = std::string(1'024, 'x') + "hit\n"; +}; + struct AllocationTest : ::testing::Test { void SetUp() override { const bool installed = InstallHooks(); @@ -381,6 +390,44 @@ TEST_F(AllocationTest, PackedVisitTextAvoidsIndependentLongStringAllocations) { // Isolated coordinator allocation counts only. Do not use sanitizer duration as timing evidence. } +TEST_F(AllocationTest, RecordRepeatedMatcherOutputAllocationTraffic) { + const OutputAllocationFs fs; + const vfs::Metadata metadata{.type = vfs::FileType::kRegular}; + const auto entries = [&] { + std::vector result; + result.reserve(64); + for (std::size_t index = 0; index < 64; ++index) { + result.emplace_back(Visit{.path = "root/a", .name = "a", .root = "root", .metadata = metadata, .fs = fs}); + } + return result; + }; + MBO_ASSERT_OK_AND_ASSIGN(const auto command, parser::Parse({"root", "-content", "hit"})); + MBO_ASSERT_OK_AND_ASSIGN(const auto prepared, PrepareMatchOutput(*command.expression)); + ParallelMatch matcher( + *command.expression, 1, false, + ParallelContentOutput{ + .output = prepared, + .grep = {.line_number = false, .filename = false}, + }); + EXPECT_THAT(matcher.Match(entries()), SizeIs(64)); + for (const int repetition : {1, 2}) { + auto input = entries(); + const auto measured = MeasureAllocations([&] { + std::size_t bytes = 0; + for (const auto& result : matcher.Match(std::move(input))) { + if (result.content) { + bytes += result.content->text.size(); + } + } + return bytes; + }); + EXPECT_THAT(measured.value, Eq(64 * 1'028)); + RecordCounts(absl::StrCat("matcher_output_", repetition), measured.counts); + } + // Only the coordinator is recorded; one worker keeps content reads and output allocations here. + // Inputs, warmup, assertions and source-fixture construction are outside recording. +} + TEST_F(AllocationTest, ReusedFuzzyWorkspaceAvoidsHotScoreAllocations) { const fuzzy::FzfQuery query("fl"); fuzzy::ScoreWorkspace workspace; diff --git a/xff/engine/parallel_match.cc b/xff/engine/parallel_match.cc index 88301050884..9801f0009e0 100644 --- a/xff/engine/parallel_match.cc +++ b/xff/engine/parallel_match.cc @@ -64,8 +64,17 @@ bool ParallelMatch::Finished() const { const std::vector& ParallelMatch::Match(std::vector entries) { const absl::MutexLock lock(mutex_); entries_ = std::move(entries); - results_.clear(); results_.resize(entries_.size()); + if (output_ && !results_.empty()) { + const auto limit = std::min(kReusableRecordBytes, kReusableOutputBytes / results_.size()); + // Release oversized old records before evaluating any new entry. Current output can still + // exceed this bound; only capacities retained from the consumed batch are restricted. + for (auto& result : results_) { + if (result.content && result.content->text.capacity() > limit) { + result.content.reset(); + } + } + } next_.store(0, std::memory_order_relaxed); // Very small batches cannot amortize waking the pool. if (workers_ == 1 || entries_.size() < 16 || (threads_.empty() && entries_.size() < 64)) { @@ -138,18 +147,22 @@ void ParallelMatch::EvaluateEntries( } const std::size_t end = std::min(first + kChunk, entries_.size()); for (std::size_t index = first; index < end; ++index) { - results_.at(index) = EvaluateEntry(entries_.at(index).AsVisit(), workspace, output, matchers, evaluator); + EvaluateEntry(index, workspace, output, matchers, evaluator); } } } -ParallelResult ParallelMatch::EvaluateEntry( - const Visit& visit, +void ParallelMatch::EvaluateEntry( + std::size_t index, fuzzy::ScoreWorkspace& workspace, mbo::types::OptionalRef> output, mbo::types::OptionalRef matchers, - mbo::types::OptionalRef evaluator) const { - ParallelResult result; + mbo::types::OptionalRef evaluator) { + const Visit visit = entries_.at(index).AsVisit(); + auto& result = results_.at(index); + if (result.content) { + result.content->text.clear(); + } Control control; std::optional fuzzy; const auto discard = [](std::string_view) {}; @@ -168,7 +181,8 @@ ParallelResult ParallelMatch::EvaluateEntry( : expression_ ? EvaluateDeferred(*expression_, context) : EvaluationResult{.matched = true}; if (result.evaluation.matched && output_) { - auto& content = result.content.emplace(); + auto& content = result.content ? *result.content : result.content.emplace(); + content.selected = true; // Forking is an optimization: retain the already validated shared matcher if it fails. const auto& prepared = output && output->ok() ? **output : output_->output; const auto buffer = [&](std::string_view text) { content.text.append(text); }; @@ -185,8 +199,9 @@ ParallelResult ParallelMatch::EvaluateEntry( } else { EmitMatchOutput(prepared, context); } + } else { + result.content.reset(); } - return result; } } // namespace xff::engine diff --git a/xff/engine/parallel_match.h b/xff/engine/parallel_match.h index 7a67edc9e77..619017fd589 100644 --- a/xff/engine/parallel_match.h +++ b/xff/engine/parallel_match.h @@ -54,6 +54,10 @@ bool HasContentMatch(const parser::Expr& expression); class ParallelMatch final { public: + // Reuse limits apply between batches, not to the current output returned to the caller. + static constexpr std::size_t kReusableOutputBytes = 1'048'576; + static constexpr std::size_t kReusableRecordBytes = 65'536; + ParallelMatch( mbo::types::OptionalRef expression, std::size_t workers, @@ -81,12 +85,13 @@ class ParallelMatch final { mbo::types::OptionalRef> output = {}, mbo::types::OptionalRef matchers = {}, mbo::types::OptionalRef evaluator = {}); - ParallelResult EvaluateEntry( - const Visit& visit, + // Each worker exclusively owns the claimed result slot until this batch completes. + void EvaluateEntry( + std::size_t index, fuzzy::ScoreWorkspace& workspace, mbo::types::OptionalRef> output, mbo::types::OptionalRef matchers, - mbo::types::OptionalRef evaluator) const; + mbo::types::OptionalRef evaluator); const mbo::types::OptionalRef expression_; const std::size_t workers_; diff --git a/xff/engine/rg_test.cc b/xff/engine/rg_test.cc index a00e7fac88b..0bcdfd622dd 100644 --- a/xff/engine/rg_test.cc +++ b/xff/engine/rg_test.cc @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include "gtest/gtest.h" #include "mbo/testing/matchers.h" #include "mbo/testing/status.h" +#include "xff/engine/evaluate.h" #include "xff/engine/owned_visit.h" #include "xff/engine/parallel_match.h" #include "xff/engine/run.h" @@ -46,15 +48,22 @@ using ::mbo::testing::IsOk; using ::mbo::testing::IsOkAndHolds; using ::mbo::testing::StatusIs; using ::mbo::testing::WithDropIndent; +using ::testing::AllOf; using ::testing::Contains; using ::testing::ElementsAre; using ::testing::Eq; +using ::testing::Field; +using ::testing::Ge; using ::testing::Gt; using ::testing::HasSubstr; using ::testing::IsEmpty; using ::testing::IsFalse; using ::testing::IsTrue; +using ::testing::Le; using ::testing::Not; +using ::testing::Optional; +using ::testing::Property; +using ::testing::SizeIs; // A restarted cursor reports a failure after an otherwise selected line. class FailingSource final : public vfs::ReadSource { @@ -206,6 +215,16 @@ struct RgEngineTest : ::testing::Test { std::vector errors; std::function after_emit; + std::vector RepeatedEntries(std::size_t count) const { + const vfs::Metadata metadata{.type = vfs::FileType::kRegular}; + std::vector entries; + entries.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + entries.emplace_back(Visit{.path = "tree/a", .name = "a", .root = "tree", .metadata = metadata, .fs = fs}); + } + return entries; + } + RunResult Run(std::vector args, bool rg = true) { // Keep record identity explicit here; CLI integration covers automatic filename prefixes. if (rg) { @@ -265,6 +284,99 @@ TEST_F(RgEngineTest, NativeMatchOutputReusesContentWithinEachEntry) { } } +TEST_F(RgEngineTest, ResultBuffersReuseSmallOutputAndDiscardGiantAndTruncatedTails) { + MBO_ASSERT_OK_AND_ASSIGN(const auto command, parser::Parse({"tree", "-content", "hit"})); + MBO_ASSERT_OK_AND_ASSIGN(const auto prepared, PrepareMatchOutput(*command.expression)); + ParallelMatch matcher( + *command.expression, 4, false, + ParallelContentOutput{ + .output = prepared, + .grep = {.line_number = false, .filename = false}, + }); + fs.files["tree/a"] = std::string(1'024, 'x') + "hit\n"; + const auto& initial = matcher.Match(RepeatedEntries(64)); + EXPECT_THAT(initial, SizeIs(64)); + fs.files["tree/a"] = "hit\n"; + const auto& reused = matcher.Match(RepeatedEntries(64)); + for (const auto& result : reused) { + EXPECT_THAT( + result.content, + Optional(AllOf( + Field(&ContentResult::selected, IsOkAndHolds(IsTrue())), + Field(&ContentResult::text, AllOf(EqualsText("hit\n"), Property(&std::string::capacity, Ge(1'024))))))); + } + fs.files["tree/a"] = std::string((ParallelMatch::kReusableOutputBytes / 64) + 100, 'x') + "hit\n"; + EXPECT_THAT(matcher.Match(RepeatedEntries(64)), SizeIs(64)); + fs.files["tree/a"] = "hit\n"; + for (const auto& result : matcher.Match(RepeatedEntries(64))) { + EXPECT_THAT( + result.content, + Optional(Field( + &ContentResult::text, + AllOf( + EqualsText("hit\n"), Property(&std::string::capacity, Le(ParallelMatch::kReusableOutputBytes / 64)))))); + } + fs.files["tree/a"] = std::string(2 * ParallelMatch::kReusableRecordBytes, 'x') + "hit\n"; + EXPECT_THAT(matcher.Match(RepeatedEntries(64)), SizeIs(64)); + fs.files["tree/a"] = "hit\n"; + const auto& tail = matcher.Match(RepeatedEntries(1)); + ASSERT_THAT(tail, SizeIs(1)); + EXPECT_THAT( + tail.front().content, + Optional(Field( + &ContentResult::text, + AllOf(EqualsText("hit\n"), Property(&std::string::capacity, Le(ParallelMatch::kReusableRecordBytes)))))); + fs.files["tree/a"] = "miss\n"; + for (const auto& result : matcher.Match(RepeatedEntries(1))) { + EXPECT_THAT(result.evaluation.matched, IsFalse()); + EXPECT_THAT(result.content, Eq(std::nullopt)); + } + EXPECT_THAT(matcher.Match(RepeatedEntries(0)), IsEmpty()); + fs.files["tree/a"] = "hit\n"; + for (const auto& result : matcher.Match(RepeatedEntries(128))) { + EXPECT_THAT(result.evaluation.matched, IsTrue()); + EXPECT_THAT(result.content, Optional(Field(&ContentResult::text, EqualsText("hit\n")))); + } +} + +TEST_F(RgEngineTest, ReusedRgResultSlotsClearSelectionErrorsAndOldOutput) { + MBO_ASSERT_OK_AND_ASSIGN(const auto command, parser::Parse({"--rg", "hit", "tree"})); + MBO_ASSERT_OK_AND_ASSIGN(const auto prepared, PrepareRgOutput(command, fs, false, false)); + const auto search = command.rg.value_or(parser::RgSearch{}); + ParallelMatch matcher( + {}, 4, false, + ParallelContentOutput{ + .output = prepared, + .rg = search, + .grep = {.match_output = true, .line_number = false, .filename = false}, + }); + for (const bool fail : {true, false, true, false}) { + fs.read_error = fail; + fs.files["tree/a"] = "hit\n"; + const auto& results = matcher.Match(RepeatedEntries(64)); + for (const auto& result : results) { + if (fail) { + EXPECT_THAT( + result.content, + Optional( + Field(&ContentResult::selected, StatusIs(absl::StatusCode::kPermissionDenied, HasSubstr("denied"))))); + } else { + EXPECT_THAT( + result.content, Optional(AllOf( + Field(&ContentResult::selected, IsOkAndHolds(IsTrue())), + Field(&ContentResult::text, EqualsText("hit\n"))))); + } + } + } + fs.files["tree/a"] = "miss\n"; + for (const auto& result : matcher.Match(RepeatedEntries(1))) { + EXPECT_THAT( + result.content, + Optional( + AllOf(Field(&ContentResult::selected, IsOkAndHolds(IsFalse())), Field(&ContentResult::text, IsEmpty())))); + } +} + TEST_F(RgEngineTest, NativeParallelBatchesReuseReadsWithoutSharingEntryContent) { fs.files.clear(); for (std::size_t index = 0; index < 300; ++index) {