Skip to content

feat(engine): graduated Soft-pressure shed via memory limiter soft_action#3363

Open
timr-dev wants to merge 4 commits into
open-telemetry:mainfrom
timr-dev:memory-aware-limiter
Open

feat(engine): graduated Soft-pressure shed via memory limiter soft_action#3363
timr-dev wants to merge 4 commits into
open-telemetry:mainfrom
timr-dev:memory-aware-limiter

Conversation

@timr-dev

@timr-dev timr-dev commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What

PR1 of #2425 (follow-up to the process-wide memory limiter). Adds an opt-in
soft_action to the memory limiter policy so operators can choose to shed
ingress as soon as Soft pressure is reached, instead of waiting for
Hard.

memory_limiter:
  mode: enforce
  soft_action: shed   # default: observe
  • observe (default) — byte-identical to today: Soft stays advisory, only
    Hard sheds.
  • shed — reject ingress (with Retry-After) as soon as Soft pressure is
    classified.

All three should_shed_ingress() sites (process-wide MemoryPressureState,
SharedReceiverAdmissionState, LocalReceiverAdmissionState) now funnel
through a single shed_decision(mode, level, soft_action) predicate, removing
the prior copy-pasted shed condition.

Public-contract preservation

  • should_shed_ingress() -> bool — signature unchanged; result byte-identical
    for the default soft_action == Observe.
  • MemoryPressureChanged event payload — unchanged. soft_action is config,
    snapshotted into receiver state via from_process_state, never broadcast.
  • SoftAction is a closed enum (observe / shed), so serde rejects an
    unbacked value (throttle, shed_low_priority) as an unknown enum
    variant
    at config load rather than silently ignoring it. Separately,
    MemoryLimiterPolicy carries #[serde(default)] + deny_unknown_fields, so
    existing configs deserialize unchanged and unknown config keys are rejected.

Performance

Criterion, single-threaded, result validated under black_box (real
materialized bool, no lazy wrapper). Compared against the committed baseline
harness (first commit in this PR).

Workload (shared / local) Baseline This PR Target Result
normal ~0.97 ns 0.868 / 0.843 ns ≤1.05× ✅ no regression
hard ~0.97 ns 0.988 / 0.975 ns ≤1.05× ✅ +1.9% / no change
soft ~0.97 ns 1.186 / 1.202 ns n/a ⚠️ +0.22 ns (see note)

Soft-path note (honest accounting): the benchmarked ingress hot path is the
receiver-local admission state, where soft_action is a plain snapshotted
field, not an atomic
— so this change adds no atomic load to the measured
path (it still does the single level atomic load it always did). The ~+0.22 ns
on Soft is extra decision logic on the Soft arm: reading the plain soft_action
byte and evaluating matches!(soft_action, Shed), work that did not exist in the
baseline where a Soft level fell through level == Hard as a single false
comparison. The normal and hard arms return without consulting soft_action,
so they show no regression. The normal "improvement" is not claimed as a
win — at sub-nanosecond scale it is within code-layout / measurement variance.

Separately, the process-wide MemoryPressureState::should_shed_ingress() API
(not the benchmarked receiver path) does add one relaxed AtomicU8 load of
soft_action; it is sub-nanosecond and off the measured hot path. A lazy load
there (only when level == Soft) was considered and deferred — the real
ingress hot path is unaffected, the load is LLVM-sinkable, and keeping a single
shed_decision predicate as the one source of truth for all three sites is
worth more than a speculative sub-ns on a non-hot path.

Memory / footprint

  • No new heap allocations; nothing added to any per-request path.
  • +1 AtomicU8 on the process MemoryPressureStateInner and +1 SoftAction
    (1 byte) on each receiver admission-state inner — absorbed by existing
    alignment padding. Fixed cost, O(number-of-limiters), not O(traffic).

Tests

cargo xtask check green (structure + cargo fmt + clippy --all-targets -D warnings + cargo test --workspace). +8 new tests:

Category Tests
Contract-pinning exhaustive 12/12 mode × level × action oracle table; the same oracle re-asserted through all three should_shed_ingress() sites (process / shared / local) to catch single-site drift; serde default + reject-unknown
Behavioral process+receiver shed parity; ObserveOnly overrides soft_action: shed
Default-preservation default soft_action keeps Soft advisory (byte-identical to pre-PR1)
Gap-closing stale-generation no-op; Retry-After: 0 clamped to 1

Suite totals: engine lib 526 · config 66 (policy) · controller 105 — all pass.

Scope / follow-ups

This is the predicate + config plumbing. Deliberately not in this PR:

  • Receiver coverage audit (ensuring every ingress consumer honors the new Soft
    shed) — next PR.
  • Shed-event observability (metrics/logs distinguishing Soft vs Hard sheds, and
    attributing sheds to the originating receiver/signal) — follow-up PR.
  • A richer graduated response (throttle / shed_low_priority) — deferred; it
    needs a rate-limit primitive and a priority model that don't exist yet, so the
    config surface intentionally rejects those values today rather than expose a
    no-op knob.

Scope: this is a process-wide backstop, not a per-source limiter

The memory limiter protects total process memory; its usage accounting and
shed decision are global and apply uniformly to all ingress — it does not
attribute usage to, or shed selectively by, individual clients/sources.
soft_action is the global default behavior. Selective per-source shedding
(rejecting only an over-limit source while others keep flowing) and per-source
attribution belong to a separate per-source resource limiter layered above
this backstop; this PR is forward-compatible with that (the flat soft_action
maps cleanly onto a future per-bucket default + overrides) and intentionally
does not pre-build it. The SoftAction doc comment records this scope inline.

Closes part of #2425.

@timr-dev
timr-dev requested a review from a team as a code owner June 26, 2026 03:35
Copilot AI review requested due to automatic review settings June 26, 2026 03:35
@github-actions github-actions Bot added area:pipeline Rust Pipeline Related Tasks lang:rust Pull requests that update Rust code area:engine Internal engine features area:engine/config User-facing configuration of the engine size/XL labels Jun 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an opt-in soft_action to the process-wide memory limiter policy to allow ingress shedding at Soft pressure (in addition to existing Hard shedding), while preserving byte-identical default behavior (soft_action: observe). The change consolidates ingress-shed decision logic into a single predicate shared across process-wide and receiver-local admission paths, and introduces a Criterion micro-benchmark to guard hot-path performance.

Changes:

  • Extend MemoryLimiterPolicy with soft_action (serde default + unknown-variant rejection via enum deserialization).
  • Centralize the ingress shedding predicate via shed_decision(mode, level, soft_action) and thread soft_action through process + receiver admission states.
  • Add a new memory_admission Criterion benchmark to measure should_shed_ingress() and shared-state contention behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
rust/otap-dataflow/crates/otap/src/otap_grpc.rs Updates tests to include soft_action in MemoryPressureBehaviorConfig setup.
rust/otap-dataflow/crates/otap/src/memory_pressure_layer.rs Updates tests to include soft_action in memory-pressure behavior configuration.
rust/otap-dataflow/crates/engine/src/memory_limiter.rs Implements SoftAction, adds shed_decision, wires soft_action through process and receiver admission state, and adds exhaustive matrix tests.
rust/otap-dataflow/crates/engine/src/engine_metrics.rs Updates tests to include soft_action when configuring MemoryPressureState.
rust/otap-dataflow/crates/controller/src/lib.rs Plumbs soft_action from MemoryLimiterPolicy into the engine’s MemoryPressureBehaviorConfig.
rust/otap-dataflow/crates/config/src/policy.rs Adds SoftAction enum and soft_action field to the memory limiter policy with defaulting behavior.
rust/otap-dataflow/benchmarks/Cargo.toml Registers the new memory_admission benchmark target.
rust/otap-dataflow/benchmarks/benches/memory_admission/main.rs Adds micro-benchmarks for should_shed_ingress() (shared/local) and a contention benchmark with background readers + updater.
rust/otap-dataflow/.chloggen/memory-limiter-soft-action.yaml Adds a changelog entry for the user-facing memory limiter policy enhancement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.69231% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 86.72%. Comparing base (c8dc3a5) to head (aa58de6).
⚠️ Report is 93 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3363      +/-   ##
==========================================
+ Coverage   86.23%   86.72%   +0.49%     
==========================================
  Files         739      789      +50     
  Lines      291248   319949   +28701     
==========================================
+ Hits       251148   277476   +26328     
- Misses      39576    41949    +2373     
  Partials      524      524              
Components Coverage Δ
otap-dataflow 87.76% <99.69%> (+0.46%) ⬆️
query_engine 89.57% <ø> (-0.01%) ⬇️
otel-arrow-go 52.45% <ø> (ø)
quiver 92.20% <ø> (+<0.01%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@timr-dev
timr-dev force-pushed the memory-aware-limiter branch 2 times, most recently from a640387 to 561f9aa Compare June 26, 2026 18:20
Phase-3 (benchmark-first) baseline for the upcoming graduated Soft-pressure
shed (issue open-telemetry#2425). Benches should_shed_ingress() on Shared/Local receiver
admission states across Normal/Soft/Hard + a read-under-flips contention
workload, using only the public memory_limiter API + black_box.

Baseline: ~0.97 ns single-thread; 1.6-2.6 ns under contention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@timr-dev
timr-dev force-pushed the memory-aware-limiter branch from 561f9aa to 4416fd6 Compare June 26, 2026 18:23
…tion

Adds an opt-in `soft_action` to the memory limiter policy so operators can
choose to shed ingress as soon as Soft pressure is reached, instead of waiting
for Hard. The default (`Observe`) is byte-identical to the prior behavior:
Soft stays advisory and only Hard sheds. This is PR1 of issue open-telemetry#2425 (Phase 2 of
the process-wide memory limiter).

A single `shed_decision(mode, level, soft_action)` predicate is now the one
source of truth for all three `should_shed_ingress()` sites (process-wide,
shared-receiver, local-receiver), eliminating the prior copy-paste of the shed
condition.

Public-contract preservation (verified against baseline):
- `MemoryPressureState::should_shed_ingress() -> bool` — unchanged signature;
  result byte-identical for the default `soft_action == Observe`.
- `SharedReceiverAdmissionState` / `LocalReceiverAdmissionState`
  `should_shed_ingress()` / `retry_after_secs()` — unchanged.
- `MemoryPressureChanged` event payload — UNCHANGED. `soft_action` is config,
  snapshotted into receiver state via `from_process_state`, never broadcast.
- `MemoryLimiterPolicy` carries `#[serde(default)]` + `deny_unknown_fields`, so
  existing configs deserialize unchanged and an unbacked value
  (`"throttle"`, `"shed_low_priority"`) is rejected at load rather than
  silently ignored.

Performance (criterion, single-thread, vs committed baseline; black_box'd bool):
  workload            baseline   this       target     result
  shared/normal       ~0.97 ns   0.868 ns   <=1.05x    PASS (no regression)
  local/normal        ~0.97 ns   0.843 ns   <=1.05x    PASS (no regression)
  shared/hard         ~0.97 ns   0.988 ns   <=1.05x    PASS (+1.9%)
  local/hard          ~0.97 ns   0.975 ns   <=1.05x    PASS (no change)
  shared/soft         ~0.97 ns   1.186 ns   n/a        +0.22 ns (see note)
  local/soft          ~0.97 ns   1.202 ns   n/a        +0.22 ns (see note)

Soft-path note (honest accounting): the benchmarked ingress hot path is the
receiver-local admission state (`SharedReceiverAdmissionState` /
`LocalReceiverAdmissionState`), where `soft_action` is a PLAIN snapshotted field,
not an atomic. So this change adds NO atomic load to the measured path — it still
performs the single `level` atomic load it always did. The ~+0.22 ns on Soft is
extra decision logic on the Soft arm: reading the plain `soft_action` byte and
evaluating `matches!(soft_action, Shed)`, work that did not exist in the baseline
where a Soft level fell through `level == Hard` as a single false comparison. The
Normal and Hard arms return without consulting `soft_action`, so they show no
regression. (Separately, the process-wide `MemoryPressureState::should_shed_ingress()`
API — NOT the benchmarked receiver path — does add one relaxed `AtomicU8` load of
`soft_action`; that is sub-nanosecond and off the measured hot path.)

Memory / footprint:
- No new heap allocations; nothing added to any per-request path.
- +1 `AtomicU8` on the process `MemoryPressureStateInner` and +1 `SoftAction`
  (1 byte) on each receiver admission-state inner — absorbed by existing
  alignment padding. Fixed, O(number-of-limiters), not O(traffic).

Test coverage (cargo xtask check: full workspace green):
  category            tests
  existing (preserved)  all engine/config/controller/otap suites unchanged
  behavioral (new)      soft_action_shed_sheds_soft_in_process_and_receiver,
                        observe_only_overrides_soft_action_shed
  contract-pinning      shed_decision_covers_full_mode_level_action_matrix
                        (golden 2x3x2 mode x level x action truth table),
                        all_three_sites_agree_with_oracle_across_full_matrix
                        (same oracle through process/shared/local sites),
                        default_soft_action_keeps_soft_advisory,
                        memory_limiter_soft_action_serde_default_and_rejects_unknown
  gap-closing           stale_generation_does_not_change_shed_decision,
                        retry_after_zero_is_clamped_to_one
  totals                engine lib 526 passed; config 66 (policy) passed;
                        controller 105 passed; +8 new tests for this change

Key changes:
- `policy.rs`: new `SoftAction { Observe(default), Shed }` enum (serde
  snake_case, JsonSchema, repr(u8)) + `#[serde(default)] soft_action` field.
- `memory_limiter.rs`: `shed_decision()` predicate; `soft_action` plumbed into
  process state (atomic) + both receiver states (plain field) + behavior config
  + `from_process_state` snapshot; all three `should_shed_ingress()` rewritten
  to call the shared predicate.
- `controller/lib.rs`: maps `MemoryLimiterPolicy.soft_action` into the
  `MemoryPressureBehaviorConfig` it configures.
- docs: `SoftAction` carries a scope note (process-wide backstop, not per-client);
  reconciled now-stale "Soft is informational only / Hard-only" wording across
  `memory_pressure_layer.rs`, `MemoryPressureLevel::Soft`, `config/README.md`,
  `docs/configuration-model.md`, and `docs/memory-limiter-phase1.md` so they
  reflect that `soft_action: shed` makes `Soft` shed ingress.

Audit summary:
- Reviewed by a 5-model panel (Sonnet, Opus, GPT-5.5, Gemini, Codex); 0
  MUST-FIX. Adopted: a table-driven 12-case parity test across all three shed
  sites (catches single-site drift), and this corrected perf attribution.
- The "improvement" on Normal/local is not claimed as a feature win — at
  sub-nanosecond scale it is within code-layout/measurement variance; the
  defensible claim is no regression on the steady-state and existing-shed paths.
- Bench validates a real materialized `bool` under `black_box`, so there is no
  lazy-wrapper inflation; the Soft regression is genuine, explained
  decision-logic work (a plain-byte read + one branch), not measurement noise.
- Second doc-accuracy panel (Opus/GPT-5.5/Gemini/Codex) on the scope note: 0
  MUST-FIX; adopted a wording fix (avoid overloading the term `source`, which
  already names the memory sampling source) and reconciled 6 stale doc sites the
  feature had invalidated.

Files:
  rust/otap-dataflow/crates/config/src/policy.rs
  rust/otap-dataflow/crates/engine/src/memory_limiter.rs
  rust/otap-dataflow/crates/engine/src/engine_metrics.rs
  rust/otap-dataflow/crates/controller/src/lib.rs
  rust/otap-dataflow/crates/otap/src/otap_grpc.rs
  rust/otap-dataflow/crates/otap/src/memory_pressure_layer.rs
  rust/otap-dataflow/crates/config/README.md
  rust/otap-dataflow/docs/configuration-model.md
  rust/otap-dataflow/docs/memory-limiter-phase1.md
  rust/otap-dataflow/.chloggen/memory-limiter-soft-action.yaml

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@timr-dev
timr-dev force-pushed the memory-aware-limiter branch from 4416fd6 to a396e25 Compare June 26, 2026 21:06
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jun 26, 2026
@lalitb

lalitb commented Jun 29, 2026

Copy link
Copy Markdown
Member

Nit: Can we update rust/otap-dataflow/docs/memory-limiter-phase1.md too? The “Receiver Behavior Under Hard Pressure” section still says Soft never rejects, but with soft_action: shed, Soft can now use the same receiver shedding behavior in enforce mode.

Also, should we rename memory-limiter-phase1.md to something like global-memory-limiter.md and replace the “Phase 1” wording in the title/intro with “global/ process-wide memory limiter”? This PR still changes the global limiter, while the retained-work budgeting / tenant/group isolation direction is tracked separately in #3272. A behavior-based doc name may avoid Phase 1/Phase 2 confusion as both efforts evolve.

For the same reason, could we also reword “Phase 2 of the process-wide memory limiter” in the PR description? Maybe “follow-up to the process-wide memory limiter” would be clearer.

@lalitb lalitb 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.

Thanks. LGTM. Good to have doc update before merge.

…ion: shed

The "Receiver Behavior Under Hard Pressure" section implied Soft pressure
never sheds, which is inaccurate now that `soft_action: shed` lets Soft
pressure shed ingress in `enforce` mode. Reframe the section around shedding
states: ingress is shed at `Hard` (always, in `enforce`) and at `Soft` when
`soft_action: shed`. Clarify that the default `soft_action: observe`
preserves the prior Hard-only behavior, and that `observe_only` mode still
overrides `soft_action` and accepts all requests.

Doc-only change; no code or public-contract changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@timr-dev

Copy link
Copy Markdown
Contributor Author

[timr-dev] Thanks for the review! Addressing the three points:

  1. Doc accuracy — Done in 5c4873c. Renamed the section from "Receiver Behavior Under Hard Pressure" to "Receiver Behavior When Shedding Ingress" and reframed it around shedding states: ingress is shed at Hard (always, in enforce mode) and at Soft when soft_action: shed, using the same per-receiver protocol-native signals (OTLP 503 / gRPC & OTAP RESOURCE_EXHAUSTED / Syslog drops) and the same receiver-level rejection counters. The default soft_action: observe still preserves the prior Hard-only behavior, and observe_only mode still overrides soft_action and accepts all requests. Verified line-by-line against the shed_decision(mode, level, soft_action) predicate in memory_limiter.rs.

  2. PR description — Done. Reworded "Phase 2 of the process-wide memory limiter" → "follow-up to the process-wide memory limiter".

  3. Rename memory-limiter-phase1.mdglobal-memory-limiter.md + drop the "Phase 1" wording — I'd prefer to keep this out of this PR and do it as a dedicated follow-up. The rename touches cross-references in docs/configuration.md (which this PR otherwise doesn't change), and the "Phase 1 → global / process-wide" reframing is really part of the naming evolution tracked in Design and implement logical retained-work memory budgeting #3272, so it reads cleaner as its own focused change than mixing a doc-wide rename into the soft_action feature PR. I'm happy to open that follow-up right after this merges — target name global-memory-limiter.md, intro reworded to "global / process-wide memory limiter". Let me know if you'd rather I bundle it here instead.

@lalitb

lalitb commented Jun 30, 2026

Copy link
Copy Markdown
Member

Rename memory-limiter-phase1.md → global-memory-limiter.md + drop the "Phase 1" wording — I'd prefer to keep this out of this PR and do it as a dedicated follow-up. The rename touches cross-references in docs/configuration.md (which this PR otherwise doesn't change), and the "Phase 1 → global / process-wide" reframing is really part of the naming evolution tracked in #3272, so it reads cleaner as its own focused change than mixing a doc-wide rename into the soft_action feature PR. I'm happy to open that follow-up right after this merges — target name global-memory-limiter.md, intro reworded to "global / process-wide memory limiter". Let me know if you'd rather I bundle it here instead.

Agree on this.

@timr-dev

Copy link
Copy Markdown
Contributor Author

[timr-dev] Thanks — agreed. I'll open the dedicated follow-up right after this merges: rename memory-limiter-phase1.mdglobal-memory-limiter.md, rework the intro to "global / process-wide memory limiter", and update the cross-references in docs/configuration.md, tracked under #3272. Keeping this PR scoped to the soft_action feature.

@jmacd jmacd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed critically and validated locally (checked out the branch, ran targeted tests + compiled the new bench). Approving — everything below is non-blocking.

Validation

  • config 66 passed, engine::memory_limiter 24 passed (incl. all 8 new), memory_admission bench compiles, clippy clean on config.
  • All three should_shed_ingress() sites and every ingress consumer (otlp/otap/syslog_cef/user_events receivers + the tower layer) route through the new predicate; no stray == Hard shed comparisons remain.
  • soft_action propagation mirrors the existing mode pattern exactly (atomic on the process inner, plain field on receiver inners), and configure() runs before receivers snapshot.
  • Default path is byte-identical (shed_decision(_, _, Observe) == old Enforce && Hard). Hysteresis is sound: with soft_action: shed, the existing Soft→Normal reopen threshold (soft_limit − hysteresis) becomes the shed-reopen point, so no oscillation.
  • Doc sweep is thorough — grepped untouched receiver .rs/README files for stale "Soft is informational / Hard-only" wording; none remain.

Non-blocking notes

  1. Process-wide MemoryPressureState::should_shed_ingress() has no production caller — receivers only consult their snapshot copies (Shared/LocalReceiverAdmissionState). The extra relaxed soft_action atomic load on that method (and the two paragraphs justifying it) doesn't touch any real hot path. Not wrong, just more analysis than the call graph warrants.

  2. Wording nit in the description: soft_action: "throttle" is rejected because serde rejects unknown enum variants by default — deny_unknown_fields is a separate mechanism that governs unknown struct fields. Code and the reject-unknown test are correct; only the write-up conflates the two.

  3. No end-to-end test that the HTTP/gRPC layer actually rejects at Soft when soft_action: shed. MemoryPressureLayer is the middleware that converts a shed decision into a real 503/RESOURCE_EXHAUSTED. Its only existing test, soft_pressure_remains_advisory, exercises just the default (Soft does not shed). The new shed decision is well covered — all_three_sites_agree_with_oracle_across_full_matrix asserts SharedReceiverAdmissionState::should_shed_ingress() returns true at Soft when shed, which is the exact call the layer makes. So the only thing not asserted is the trivial "layer forwards that true into a rejection" step. Low value — fine to fold into the receiver-coverage follow-up you already scoped.

  4. The "Baseline" column in the perf table can't be reproduced after merge. Commit 1 adds the bench harness and commit 2 makes the behavior change, both in this PR — so there is no commit on main that has the bench but not the change. Anyone who checks out merged main and runs memory_admission gets only the "This PR" numbers. That's completely fine for the bench's actual purpose (catching future regressions against the committed code); the note is just that "Baseline" was a point-in-time dev measurement, not a number a reader can re-derive from main later.

Comment thread rust/otap-dataflow/crates/engine/src/memory_limiter.rs

@lquerel lquerel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@timr-dev Overall, this looks good to me. I think there's a small issue in the logic around the soft/hard limits combined with hysteresis.

@lalit During the review of this PR, I noticed a few things related to a previous PR on the memory limiter side that seemed suboptimal. I'll file a separate report for those.

Comment thread rust/otap-dataflow/docs/memory-limiter-phase1.md Outdated
@lalitb

lalitb commented Jul 10, 2026

Copy link
Copy Markdown
Member

@lalit During the review of this PR, I noticed a few things related to a previous PR on the memory limiter side that seemed suboptimal. I'll file a separate report for those.

Sure @lquerel. Thanks for reviewing that part. Will look forward to your findings.

@lalitb

lalitb commented Jul 13, 2026

Copy link
Copy Markdown
Member

@lalit During the review of this PR, I noticed a few things related to a previous PR on the memory limiter side that seemed suboptimal. I'll file a separate report for those.

Sure @lquerel. Thanks for reviewing that part. Will look forward to your findings.

@lquerel - I dug a bit more into the memory limiter after your comment.

The docs describe the mechanics: sampling is periodic and enforcement happens only at ingress when the state reaches Hard. So the limitation is mostly implied rather than stated directly: this can reduce new memory growth, but it is not a strict cap and it cannot immediately reclaim memory already held inside the process.

The real issues I found are more specific:

  • The default hysteresis is derived from the soft-to-hard gap and can become very large. With a wide gap, Soft can become sticky because memory must fall far below the soft limit to return to Normal. This mainly affects state reporting today because Soft does not reject ingress.
  • source: auto only checks the current/leaf cgroup limit. In nested cgroups, the real limit may be on a parent, so auto detection can miss it.
  • If memory sampling fails, the old pressure state stays in place. That can leave the limiter wrongly open or wrongly rejecting, with only a warning log.

I’ll anyway track a separate issue for these, but please let me know if you had a different issue in mind.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Jul 18, 2026

Copy link
Copy Markdown

Pull request dashboard status

Status last refreshed: 2026-07-25 23:02:00 UTC.

  • Waiting on: Author
  • Next step: Address or respond to 1 review feedback item:
    • Top-level feedback: 1
    • For each item, reply to move the discussion forward, e.g. link to the commit that addresses it, explain why no change is needed, or ask a follow-up question.

This automated status or its linked feedback items may be incorrect. If something looks wrong, please report it with the result you expected. If you believe this pull request is incorrectly routed as waiting on the author, comment /dashboard route:reviewers to route it from waiting on the author to waiting on reviewers. If the last refreshed time above predates your latest reply or push, the dashboard hasn't processed it yet.

…ry band

When `hysteresis` is omitted, the default was derived as
`min(hard_limit - soft_limit, soft_limit - 1)`. With a wide soft->hard gap
that collapsed to `soft_limit - 1`, so the Soft->Normal recovery threshold
(`usage < soft_limit - hysteresis`) landed at `usage < 1`. A `soft_action: shed`
limiter therefore latched in Soft, shedding ingress until usage fell to nearly
zero. Default `soft_action: observe` was unaffected functionally, but its
reported Soft state was equally sticky.

Derive the default band as `min(hard_limit - soft_limit, soft_limit / 10)`
instead. The new band is provably <= the old for all soft_limit >= 1, so
recovery is only ever earlier-or-equal (no slower-recovery regression). With
soft=100/hard=250 the band goes 99 -> 10, so recovery happens at usage < 90.

Contract preservation: no public API or wire-format change. Explicit-hysteresis
policies, and `source: auto` setups (soft/hard already within ~5%, where the
soft->hard gap is the min), are byte-identical. Only omitted-hysteresis policies
with a wide soft->hard gap change behavior.

Tests (otap-df-engine memory_limiter): 26 passed, 0 failed (24 -> 26).
  - Behavioral:   soft_shed_recovers_to_normal_with_default_hysteresis
                  (soft_action: shed, soft=100/hard=250, omitted hysteresis;
                  drives 100 -> 50 through tick(), asserts Soft+shed then
                  Normal+stop-shed)
  - Gap-closing:  soft_shed_recovery_boundary_uses_strict_below_soft_minus_hysteresis
                  (pins the strict `<`: usage==90 stays Soft, usage==89 -> Normal)
  - Updated:      omitted_hysteresis_defaults_to_a_value_below_soft_limit (99 -> 10)

Validation: cargo test -p otap-df-engine memory_limiter (26/26), cargo fmt
--check clean, cargo clippy -p otap-df-engine --all-targets -D warnings clean,
markdownlint clean.

Files:
  - crates/engine/src/memory_limiter.rs  (fix + 2 tests + 1 updated assertion)
  - crates/config/src/policy.rs          (hysteresis field default-derivation doc)
  - docs/memory-limiter-phase1.md        (soft_action-conditional recovery guidance)
  - .chloggen/memory-limiter-hysteresis-default.yaml (new bug_fix entry)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 56aef90d-a6b6-4149-8661-5989662dbd7c
@timr-dev

Copy link
Copy Markdown
Contributor Author

[timr-dev] Thanks all — addressed the outstanding feedback in aa58de6.

@lquerel (review + inline threads): the sticky-Soft hysteresis logic is fixed — the omitted-hysteresis default is now min(hard_limit − soft_limit, soft_limit / 10) instead of soft_limit − 1, so a soft_action: shed limiter recovers modestly below the soft limit instead of latching until usage nears zero. Regression + boundary tests and the soft_action-conditional doc are in the two inline threads above.

@lalitb (#issuecomment-4963445519): the first item — the default hysteresis growing large from a wide soft→hard gap and making Soft sticky — is fixed here. The other two (auto detection only reading the leaf cgroup; stale pressure state when sampling fails) are real and out of scope for this PR; agree they belong in your separate tracking issue — happy to help there.

@jmacd (review notes): (1) agreed the process-wide should_shed_ingress() has no prod caller today — left in place for state-reporting/consistency, no code change. (2) Fixed the description wording — throttle/shed_low_priority are rejected as unknown enum variants, distinct from deny_unknown_fields (which governs unknown struct keys). (3) The layer-forwards-true→503 step I'll fold into the receiver-coverage follow-up. (4) Fair — "Baseline" was a point-in-time dev measurement, not re-derivable from merged main; the bench's purpose is catching future regressions against the committed code.

@lalitb

lalitb commented Jul 22, 2026

Copy link
Copy Markdown
Member

@timr-dev - One context update since this PR was first opened: while you were out, #3484 landed the pressure-aware rate throttling design, and #3529 is now implementing that path. That design uses Soft pressure as the trigger for selective receiver-local throttling, where over-rate scopes are throttled while within-rate scopes continue.

Given that newer direction, should we still keep soft_action: shed in this PR as a separate global Soft-pressure emergency mode? Or should this PR avoid adding that behavior now, and leave Soft-pressure throttling to the #3529 rate-throttling implementation? I realize this may mean some rework, but I think it is worth checking now so we do not land two overlapping Soft-pressure control models.

@timr-dev

Copy link
Copy Markdown
Contributor Author

[timr-dev] @lalitb — agreed, and thanks for catching this before it landed. Two Soft-pressure control models keyed off the same signal is exactly what we want to avoid. I talked this through with my operator before replying, so this reflects a reviewed decision rather than an automated one.

Reading RFC 0002, the split is deliberate: the memory limiter stays the hard-pressure global backstop (soft = informational), and the rate policy in #3529 owns soft-pressure admission control — selectively throttling over-rate scopes while within-rate scopes continue, and reusing the same 503 / ResourceExhausted responses. A global soft_action: shed is just the blunt, all-or-nothing version of what #3529 does properly, on the identical trigger. No reason to ship both.

So I'll drop soft_action: shed from this PR and leave Soft-pressure shedding to #3529.

What I'd like to keep is the hysteresis-default bug fix (independent of soft_action): the old min(hard − soft, soft − 1) default collapsed the recovery band, so the limiter only left Soft near usage = 0. Per the RFC's Bursts and Recovery section it's the limiter's own hysteresis that governs leaving soft pressure — the very signal #3529 uses to stop throttling — so fixing it (min(hard − soft, soft / 10)) directly benefits the rate-throttling path too.

Net: this PR narrows to "fix memory-limiter hysteresis default (+docs)", ceding all Soft-pressure action to #3529. I'll rescope and re-request review. Does that match what you had in mind?

Reply posted by [GS] @timr-dev Copilot AI Assistant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:engine/config User-facing configuration of the engine area:engine Internal engine features area:pipeline Rust Pipeline Related Tasks documentation Improvements or additions to documentation lang:rust Pull requests that update Rust code size/XL

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants