feat(engine): graduated Soft-pressure shed via memory limiter soft_action#3363
feat(engine): graduated Soft-pressure shed via memory limiter soft_action#3363timr-dev wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
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
MemoryLimiterPolicywithsoft_action(serde default + unknown-variant rejection via enum deserialization). - Centralize the ingress shedding predicate via
shed_decision(mode, level, soft_action)and threadsoft_actionthrough process + receiver admission states. - Add a new
memory_admissionCriterion benchmark to measureshould_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 Report❌ Patch coverage is 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
🚀 New features to boost your workflow:
|
a640387 to
561f9aa
Compare
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>
561f9aa to
4416fd6
Compare
…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>
4416fd6 to
a396e25
Compare
|
Nit: Can we update Also, should we rename 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
left a comment
There was a problem hiding this comment.
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] Thanks for the review! Addressing the three points:
|
Agree on this. |
|
[timr-dev] Thanks — agreed. I'll open the dedicated follow-up right after this merges: rename |
jmacd
left a comment
There was a problem hiding this comment.
Reviewed critically and validated locally (checked out the branch, ran targeted tests + compiled the new bench). Approving — everything below is non-blocking.
Validation
config66 passed,engine::memory_limiter24 passed (incl. all 8 new),memory_admissionbench compiles, clippy clean onconfig.- 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== Hardshed comparisons remain. soft_actionpropagation mirrors the existingmodepattern exactly (atomic on the process inner, plain field on receiver inners), andconfigure()runs before receivers snapshot.- Default path is byte-identical (
shed_decision(_, _, Observe)== oldEnforce && Hard). Hysteresis is sound: withsoft_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
-
Process-wide
MemoryPressureState::should_shed_ingress()has no production caller — receivers only consult their snapshot copies (Shared/LocalReceiverAdmissionState). The extra relaxedsoft_actionatomic 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. -
Wording nit in the description:
soft_action: "throttle"is rejected because serde rejects unknown enum variants by default —deny_unknown_fieldsis a separate mechanism that governs unknown struct fields. Code and the reject-unknown test are correct; only the write-up conflates the two. -
No end-to-end test that the HTTP/gRPC layer actually rejects at Soft when
soft_action: shed.MemoryPressureLayeris the middleware that converts a shed decision into a real503/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_matrixassertsSharedReceiverAdmissionState::should_shed_ingress()returnstrueat Soft when shed, which is the exact call the layer makes. So the only thing not asserted is the trivial "layer forwards thattrueinto a rejection" step. Low value — fine to fold into the receiver-coverage follow-up you already scoped. -
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
mainthat has the bench but not the change. Anyone who checks out mergedmainand runsmemory_admissiongets 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 frommainlater.
lquerel
left a comment
There was a problem hiding this comment.
@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.
@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 The real issues I found are more specific:
I’ll anyway track a separate issue for these, but please let me know if you had a different issue in mind. |
Pull request dashboard statusStatus last refreshed: 2026-07-25 23:02:00 UTC.
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 |
…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] Thanks all — addressed the outstanding feedback in aa58de6. @lquerel (review + inline threads): the sticky- @lalitb (#issuecomment-4963445519): the first item — the default hysteresis growing large from a wide soft→hard gap and making @jmacd (review notes): (1) agreed the process-wide |
|
@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 |
|
[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 So I'll drop What I'd like to keep is the hysteresis-default bug fix (independent of 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. |
What
PR1 of #2425 (follow-up to the process-wide memory limiter). Adds an opt-in
soft_actionto the memory limiter policy so operators can choose to shedingress as soon as Soft pressure is reached, instead of waiting for
Hard.
observe(default) — byte-identical to today: Soft stays advisory, onlyHard sheds.
shed— reject ingress (withRetry-After) as soon as Soft pressure isclassified.
All three
should_shed_ingress()sites (process-wideMemoryPressureState,SharedReceiverAdmissionState,LocalReceiverAdmissionState) now funnelthrough a single
shed_decision(mode, level, soft_action)predicate, removingthe prior copy-pasted shed condition.
Public-contract preservation
should_shed_ingress() -> bool— signature unchanged; result byte-identicalfor the default
soft_action == Observe.MemoryPressureChangedevent payload — unchanged.soft_actionis config,snapshotted into receiver state via
from_process_state, never broadcast.SoftActionis a closed enum (observe/shed), so serde rejects anunbacked value (
throttle,shed_low_priority) as an unknown enumvariant at config load rather than silently ignoring it. Separately,
MemoryLimiterPolicycarries#[serde(default)]+deny_unknown_fields, soexisting configs deserialize unchanged and unknown config keys are rejected.
Performance
Criterion, single-threaded, result validated under
black_box(realmaterialized
bool, no lazy wrapper). Compared against the committed baselineharness (first commit in this PR).
normalhardsoftSoft-path note (honest accounting): the benchmarked ingress hot path is the
receiver-local admission state, where
soft_actionis a plain snapshottedfield, not an atomic — so this change adds no atomic load to the measured
path (it still does the single
levelatomic load it always did). The ~+0.22 nson Soft is extra decision logic on the Soft arm: reading the plain
soft_actionbyte and evaluating
matches!(soft_action, Shed), work that did not exist in thebaseline where a Soft level fell through
level == Hardas a single falsecomparison. The
normalandhardarms return without consultingsoft_action,so they show no regression. The
normal"improvement" is not claimed as awin — at sub-nanosecond scale it is within code-layout / measurement variance.
Memory / footprint
AtomicU8on the processMemoryPressureStateInnerand +1SoftAction(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 checkgreen (structure +cargo fmt+clippy --all-targets -D warnings+cargo test --workspace). +8 new tests:mode × level × actionoracle table; the same oracle re-asserted through all threeshould_shed_ingress()sites (process / shared / local) to catch single-site drift; serde default + reject-unknownObserveOnlyoverridessoft_action: shedsoft_actionkeeps Soft advisory (byte-identical to pre-PR1)Retry-After: 0clamped to 1Suite 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:
shed) — next PR.
attributing sheds to the originating receiver/signal) — follow-up PR.
throttle/shed_low_priority) — deferred; itneeds 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_actionis 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_actionmaps cleanly onto a future per-bucket default + overrides) and intentionally
does not pre-build it. The
SoftActiondoc comment records this scope inline.Closes part of #2425.