Skip to content

[libbeat/processing] Deduplicate processors based on config - #52277

Merged
orestisfl merged 6 commits into
elastic:mainfrom
orestisfl:global-shared-processors
Jul 30, 2026
Merged

[libbeat/processing] Deduplicate processors based on config#52277
orestisfl merged 6 commits into
elastic:mainfrom
orestisfl:global-shared-processors

Conversation

@orestisfl

@orestisfl orestisfl commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Proposed commit message

Deduplicate processor instances in SafeWrap using (name, hash).
Constructor calls with an identical configuration return handles to one
shared, reference-counted instance.

Beat-level global processors were already shared before this PR so most
processors should already work with that level of concurrency.

Design points:

- Exceptions:
  1. PathSetter implementations
  2. The rate_limit processor: a shared token bucket would divide the
     configured per-input limit by the number of owners.
- Each constructor call returns a handle; Close is idempotent per
  handle.
- Concurrent constructors sharing the same key wait for a single
  instance to initialize. Construction is done outside the global lock
  to avoid one processor blocking all constructors.

This reverts #50713, which shared only add_kubernetes_metadata through a
dedicated wrapper package, cherry-picks and hardens the approach from
#50586.

Processors that need to opt-out of being shared can implement the
Unshareable interface.

Closes #50376

Co-authored-by: Vihas Makwana <vihas.makwana@elastic.co>

Note

I kept four commits for cleaner reviewing:

  1. Contract tests for processors
  2. Revert [libbeat/processing] add a new shared processor wrapper #50713
  3. Re-introduce [libbeat/processing] create a unique processor for a given config #50586
  4. Harden

Checklist

  • My code follows the style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have made corresponding change to the default configuration files
  • I have added tests that prove my fix is effective or that my feature works. Where relevant, I have used the stresstest.sh script to run them under stress conditions and race detector to verify their stability.
  • I have added an entry in ./changelog/fragments using the changelog tool.

Disruptive User Impact

None directly but indirectly, inputs with identical processor configurations now share one instance.

How to test this PR locally

e2e

Filebeat config:

filebeat.inputs:
  - type: filestream
    id: metadata-repro
    paths: ["/tmp/filebeat-processors-repro/logs/*.log"]
    processors:
      - add_docker_metadata:
          host: unix:///tmp/filebeat-repro-no-docker.sock
          wait_for_metadata_timeout: 0
          wait_for_metadata_retry_period: 1h

output.discard.enabled: true
http.enabled: true
http.host: 127.0.0.1
http.port: 5066
logging.level: error
logging.to_files: false

Create files:

ROOT=/tmp/filebeat-processors-repro
mkdir -p "$ROOT/logs"
for i in $(seq 1 120); do
  printf 'container-%03d %01024d\n' "$i" 0 > "$ROOT/logs/$i.log"
done

Run each binary separately:

mkdir -p "$ROOT"/{home,data,logs}

systemd-run --user --wait --pipe \
  --unit="filebeat-repro" \
  --property=MemoryAccounting=yes \
  "$ROOT/filebeat" -e --strict.perms=false -c "$ROOT/filebeat.yml" \
  --path.home="$ROOT/home" --path.data="$ROOT/data" \
  --path.logs="$ROOT/logs" >"$ROOT/run.txt" 2>&1 &
runner=$!

until stats=$(curl --fail --silent http://127.0.0.1:5066/stats) &&
  [ "$(jq -r '.filebeat.harvester.running' <<<"$stats")" = 120 ]; do
  sleep 0.1
done
jq '{harvesters: .filebeat.harvester.running,
     goroutines: .beat.runtime.goroutines}' <<<"$stats"
systemctl --user stop "filebeat-repro.service"
wait "$runner" || true
grep 'Memory peak' "$ROOT/run.txt"
At 120 active harvesters main This PR
Goroutines 383 264
Cgroup memory peak 39.4–40.7 MB 31.9 MB

Unit tests

go test -race ./libbeat/processors ./filebeat/channel
timeout 120 ./script/stresstest.sh --race ./libbeat/processors '^TestContract|^TestShared|^TestSafe' -p 8
timeout 120 ./script/stresstest.sh --race ./filebeat/channel '^Test' -p 8

The contract tests (first commit) were written black-box against main before the implementation existed and pass unmodified on both.

Related issues

@orestisfl orestisfl self-assigned this Jul 27, 2026
@orestisfl
orestisfl requested a review from a team as a code owner July 27, 2026 15:59
@orestisfl orestisfl added the bug label Jul 27, 2026
@orestisfl orestisfl added backport-skip Skip notification from the automated backport with mergify Team:Elastic-Agent-Data-Plane Label for the Agent Data Plane team labels Jul 27, 2026
@botelastic botelastic Bot added the needs_team Indicates that the issue/PR needs a Team:* label label Jul 27, 2026
@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

Pinging @elastic/elastic-agent-data-plane (Team:Elastic-Agent-Data-Plane)

@botelastic botelastic Bot removed the needs_team Indicates that the issue/PR needs a Team:* label label Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 GitHub comments

Just comment with:

  • run docs-build : Re-trigger the docs validation. (use unformatted text in the comment!)
  • /test : Run the Buildkite pipeline.

@cmacknz cmacknz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the approach makes sense, still digesting the concurrency interactions but found only some potential ref counting bugs on the first pass.

Comment thread libbeat/processors/safe_processor.go Outdated
Comment thread libbeat/processors/safe_processor.go Outdated
Comment thread libbeat/processors/processor.go Outdated
Comment thread libbeat/processors/safe_processor.go Outdated
orestisfl and others added 3 commits July 29, 2026 09:12
…cycle

Black-box tests written against current main by an author with no
knowledge of the upcoming processor-sharing implementation, so they pin
the observable contract rather than implementation details:
construction through the registry, lifecycle (double-Close tolerance,
run after close), SetPaths semantics, cross-pipeline independence
(closing one pipeline's processors must not affect another pipeline
built from an identical config) and concurrent construct/run/close
churn, at both the libbeat/processors and filebeat/channel level.

They pass unmodified on this commit (pre-change) and must keep passing
after the sharing change lands; any commit that needs to modify them is
changing the contract and must show that in its diff.

Also extend the channel runner mock to close the per-client processor
groups, pinning that repeated Close of a group is tolerated and that
owners release their processors on shutdown.

Assisted-By: GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cherry-pick of elastic#50586 (squashed). Replaces the approach from elastic#50713
(reverted in the previous commit): instead of a dedicated shared
processor wrapper opted into per-processor, SafeWrap now dedupes
processor instances globally by (name, config hash) with reference
counting, so per-input processors are no longer instantiated per
harvester.

Fixes memory/goroutine blow-up with many harvesters (elastic#50376).

Note: the lifecycle contract tests introduced in the first commit of
this PR fail at this commit (TestContractConcurrentPipelines,
TestContractInputConcurrentHarvesters): closing a group twice releases
another owner's reference and the shared instance is closed under a
live holder. The next commit restores the contract.
@orestisfl
orestisfl force-pushed the global-shared-processors branch from 490c668 to f1a24a6 Compare July 29, 2026 07:12
@orestisfl

Copy link
Copy Markdown
Contributor Author

@cmacknz your comments are correct in the sense that the evicted core will not have an accurate refCount but an evicted core should never be re-used. I decided to not change the code to update the refCount because we would need to re-lock and a reader might question why we want to keep an accurate refCount for evicted cores.

I refactored some code around the construct-or-wait logic and commented the paths you highlighted.

@orestisfl
orestisfl force-pushed the global-shared-processors branch from f1a24a6 to de8136d Compare July 29, 2026 07:32
…-out, singleflight construction

Rework of the sharing mechanism from the previous commit, addressing
findings from adversarial review, contract tests and benchmarks:

- Each constructor call returns its own handle to the shared instance.
  Close is idempotent per handle (the documented SafeWrap contract: one
  instance can be reachable from multiple processor groups that each
  close it): a duplicate Close can no longer release another owner's
  reference and close the shared instance under a live holder.
- Processors are shared by default with two exceptions: PathSetter
  implementations (paths are owner-specific; in otel mode each beat
  receiver sets its own paths and pointer-compared paths would fail the
  second receiver) and Unshareable implementations, a new marker
  interface for processors whose semantics change when shared
  (rate_limit: one shared token bucket would divide the configured
  per-input limit by the number of owners).
- The real constructor runs outside the global lock via per-key
  singleflight: a blocking constructor (add_kubernetes_metadata with
  wait_for_metadata waits for the API server, potentially forever with
  wait_for_metadata_timeout: 0) no longer stalls all processor
  construction and teardown process-wide, and a construction panic
  cannot leave concurrent waiters blocked. The underlying Close (which
  drains in-flight Runs) also runs outside the global lock.
- Shared handles preserve the pdata fast path: a shared processor that
  implements PdataProcessor is returned as a handle that forwards
  RunPdata, and unshared Closer+PdataProcessor instances keep the
  safePdataProcessorWithClose wrapping.
- The eviction of the last reference removes the cache entry, bounding
  the shared map under autodiscover config churn.

Includes a concurrency churn stress test (same-config construct/Run/
Close churn racing a steady different-config holder of the same
processor name) designed for script/stresstest.sh; 1000+ runs with 0
failures under -race.
@orestisfl
orestisfl force-pushed the global-shared-processors branch from de8136d to 9ccee8e Compare July 29, 2026 08:26
@cmacknz

cmacknz commented Jul 29, 2026

Copy link
Copy Markdown
Member

LGTM, will let @khushijain21 do the approval since she had some comments to address as well.

@mergify

mergify Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@orestisfl
orestisfl enabled auto-merge (squash) July 30, 2026 06:25
@orestisfl
orestisfl merged commit d0be3cb into elastic:main Jul 30, 2026
205 of 208 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-skip Skip notification from the automated backport with mergify bug Team:Elastic-Agent-Data-Plane Label for the Agent Data Plane team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[beatreceiver] - Wrap add_fields with sharedprocessor add_kubernetes_metadata: Memory and harvester unexpected increase when using filestream

4 participants