Skip to content

Authoring rule #2: add a partition-weight guard rail (unplaceable-weight check) on the addWagedResource endpoint - #1

Closed
sjainit wants to merge 14 commits into
sarjain/helix-rest-guardrail-frameworkfrom
sarjain/helix-rest-guardrail-weight-rule
Closed

Authoring rule #2: add a partition-weight guard rail (unplaceable-weight check) on the addWagedResource endpoint#1
sjainit wants to merge 14 commits into
sarjain/helix-rest-guardrail-frameworkfrom
sarjain/helix-rest-guardrail-weight-rule

Conversation

@sjainit

@sjainit sjainit commented Jul 17, 2026

Copy link
Copy Markdown
Owner

What this is

A stacked demo PR on top of the guard rail framework (linkedin#213). It exists to show, concretely, how a new rule is authored on the framework — the diff here is only the incremental change: one rule, one additive context field, one endpoint wired, and tests.

Stacked on: sarjain/helix-rest-guardrail-framework (the head branch of linkedin#213). It must merge into that branch (or land after linkedin#213), hence Draft.

The new rule: PartitionWeightCapacityGuardrailRule

What it checks: for a WAGED resource, WAGED places each partition replica on exactly one instance, so a partition can only ever be placed if — for every capacity dimension dsome instance has capacity_d >= weight_d. This rule rejects addWagedResource when a partition's effective weight in any dimension exceeds the largest single instance's capacity in that dimension, because such a partition is permanently unplaceable no matter how the cluster is arranged.

Gap it closes: today addWagedResource only validates that the required weight keys are present (WagedValidationUtil.validateAndGetPartitionCapacity checks key coverage, never magnitude). So a resource whose weight is larger than any instance can hold is accepted, the write succeeds, and it only surfaces later as a WAGED rebalance failure. This rule compares magnitude against real instance capacity on the write path, so the impossible resource is rejected before it reaches ZooKeeper.

It is deliberately a necessary (not sufficient) condition — it compares each dimension independently against the best instance in that dimension, so it never blocks a resource that could plausibly be placed; it only fails the provably-impossible cases. It is a no-op for non-WAGED clusters (no capacity keys), honors cluster-level default instance/partition weights (merged in the same way the rebalancer does), and evaluates the DEFAULT partition so resources relying purely on default weights are still checked.

How it demonstrates the framework's extension points

  1. Author a rule — implement GuardrailRule: null-guard → read what it needs from the context → reuse the same weight/capacity primitives WAGED uses → return a ValidationResult (feasible, or an infeasible verdict with an actionable message). Mirrors MinActiveReplicaGuardrailRule.
  2. Add an additive context field — the rule needs the object the mutation would write, not just current cluster state, so GuardrailContext.proposedResourceConfig is added via the Builder. Existing rules/endpoints are untouched — this is the intended extensibility seam.
  3. Guard another endpoint — wired into ResourceAccessor.addResource (the addWagedResource command) with the same force / dryRun preflight the delete endpoint already uses. No registry, no dispatch — the endpoint just builds new GuardrailPipeline(new PartitionWeightCapacityGuardrailRule()).

Behavior

PUT /clusters/{cluster}/resources/{resource}?command=addWagedResource (body = map of IdealState + ResourceConfig ZNRecords):

  • enforce (no query param): a partition weight exceeding max instance capacity → 400 + JSON verdict, resource not created; within-capacity → 200, created.
  • dry-run (dryRun=true): always 200 + verdict, resource never created (validation-probe semantics, consistent with the framework).
  • force (force=true): proceeds even for an over-weight resource → 200, created, with the overridden verdict logged.

Tests

  • Rule unit tests (TestPartitionWeightCapacityGuardrailRule, 8): null proposed config, null cluster config, no capacity keys (non-WAGED), no instance capacity, weight within capacity, weight exceeding capacity (DEFAULT → unscoped), per-partition override exceeding, and max-across-instances used. Mocked HelixDataAccessor + real ClusterConfig / InstanceConfig / ResourceConfig.
  • REST integration test (TestResourceAccessor#testAddWagedResourceWeightGuardrail, 1): full Jersey + embedded ZK, exercising enforce / dryRun / force / within-capacity with status codes, the JSON verdict, and actual resource presence/absence. Saves and restores the cluster + instance capacity configuration in try/finally so it does not disturb sibling tests.

Validated on JDK 11: 8 new rule unit tests (plus the existing framework unit tests) and the new REST integration test pass.

Not in scope

  • The stricter exact check ("does any single instance fit the whole weight vector"); this PR implements the per-dimension necessary condition by design.
  • Additional rules from the design catalog.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Sarthak Jain and others added 2 commits July 16, 2026 12:57
Helix measures how long a partition's top state is missing but has no
signal for how long a partition stays below its minActiveReplicas count.
This adds a per-resource "partition recovery duration" metric, reusing the
existing top-state-handoff timing infrastructure.

New JMX metrics on ResourceMonitor (per resource):
- PartitionRecoveryDurationGauge (histogram): end-to-end recovery duration,
  the headline/SLO metric.
- PartitionRecoveryHelixLatencyGauge (histogram): Helix-controlled portion;
  registered but populated by a follow-up (v1 emits a negative latency that
  updatePartitionRecoveryStats skips).
- PartitionsRecoveryDurationBeyondThresholdGauge: partitions currently past
  the recovery threshold; the alerting signal and the only metric that
  catches a partition that never heals.
- SucceededPartitionRecoveryCounter: completed-recovery count / denominator.

Detection is wired into TopStateHandoffReportStage.updateTopStateStatus as a
per-partition edge detector over a new ResourceControllerDataProvider cache
map: on dropping below minActiveReplicas the controller stamps a start time;
on returning to the minimum it emits the elapsed duration and clears the
record. The active replica count is read from CurrentStateOutput (the
ExternalView is not computed at this stage), reusing the same active-state
definition as ResourceMonitor.updateResourceState. The alert threshold is a
new cluster-level config PARTITION_RECOVERY_DURATION_THRESHOLD (default 5m).

Includes the design doc (docs/design/004) and unit tests covering record
creation, recovery emission, beyond-threshold alerting, and cache lifecycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sjainit
sjainit force-pushed the sarjain/helix-rest-guardrail-weight-rule branch from 2d1b095 to 7a95001 Compare July 17, 2026 06:17
@sjainit sjainit changed the title Demo: add a second guard rail rule (instance-capacity weights) on the add-instance endpoint Demo: add a partition-weight guard rail (unplaceable-weight check) on the addWagedResource endpoint Jul 17, 2026
@sjainit sjainit changed the title Demo: add a partition-weight guard rail (unplaceable-weight check) on the addWagedResource endpoint Authoring rule #2: add a partition-weight guard rail (unplaceable-weight check) on the addWagedResource endpoint Jul 17, 2026
@sjainit
sjainit marked this pull request as ready for review July 17, 2026 06:48
Sarthak Jain and others added 12 commits July 18, 2026 21:53
Rename PartitionsRecoveryDurationBeyondThresholdGauge to
PartitionsRecoveryDurationBeyondThresholdCounter and change its semantics
from a point-in-time up/down gauge to a monotonic counter: incremented
once at recovery when the below-min window exceeded the threshold, and
never decremented.

A point-in-time gauge is only nonzero while a breach is in flight, so a
breach that crosses the threshold and then heals between monitoring
scrapes is invisible -- and fast-healing breaches (the shortest
over-threshold tail) are the easiest to miss. A never-decremented counter
makes breach occurrences reliably countable via increase()/rate()
regardless of scrape timing. Partitions stuck below-min indefinitely
remain covered by the existing point-in-time
MissingMinActiveReplicaPartitionGauge, so there is no coverage gap.

Removes the now-unused failed flag on MissingMinActiveReplicaRecord and
the mid-flight increment / decrement-on-heal wiring. Updates unit tests
and the design doc accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…raded-run assertions

- Rename _successRecoveryCounter -> _succeededPartitionRecoveryCounter to
  match the emitted SucceededPartitionRecoveryCounter metric name.
- Replace the two 'if (resourceMonitor != null)' silent guards in the
  degraded-run tests with explicit assertNull: a degraded-only pipeline run
  emits nothing, so no ResourceMonitor is created. Asserting null prevents a
  stray in-flight emission from passing green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address review feedback:
- ResourceMonitor.updatePartitionRecoveryStats: guard the duration histogram
  with 'totalDuration >= 0'. totalDuration is a wall-clock diff across two
  pipeline runs; a backward clock step (NTP/PTP) can make it negative and
  corrupt the histogram min/p99/max. The recovery is still counted; only the
  invalid duration sample is skipped, mirroring the existing helixLatency guard.
- TopStateHandoffReportStage.updatePartitionRecoveryStatus: skip disabled
  resources (idealState.isEnabled()), mirroring ResourceMonitor#updateResourceState.
  A disabled resource is not expected to maintain its replicas, so a drop below
  min while disabled is not a real recovery and must not be tracked or emitted.

Tests: add testDisabledResourceCreatesNoRecord and a negative-duration case in
testUpdatePartitionRecoveryStats.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A missing recovery record is ambiguous: it can mean the partition was
healthy and just dropped, but it can also mean the partition has never
been observed (a brand-new resource, a partition expansion, or the first
pipeline run after clearMonitoringRecords() wipes the in-memory records
on a leadership change). Opening a recovery window in the latter cases
mis-counts partition bring-up as a recovery, permanently polluting the
durable histogram and the monotonic beyond-threshold counter.

Gate record creation on the previously published ExternalView, which is
refreshed from ZooKeeper at the start of every pipeline run and is
durable across leadership changes. Only open a recovery window when that
ExternalView confirms the partition was at or above minActiveReplicas,
so bring-up and pre-existing degradation are not mistaken for a drop.

Refactor countActiveReplicas into a null-safe overload over an
instance->state map so it can be reused for the ExternalView state map.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
While the cluster is in maintenance mode the controller intentionally
holds off restoring or moving replicas (node swaps, take-downs, the
maintenance-timeout window), so a partition sitting below
minActiveReplicas is expected behavior rather than an availability
regression. Counting it would inflate the recovery-duration histogram by
the length of the maintenance window and fire false beyond-threshold
breaches, defeating the purpose of the metric.

Add cache.isMaintenanceModeEnabled() to the existing early return in
updatePartitionRecoveryStatus (alongside the null/disabled checks), so
below-min partitions are ignored cluster-wide while maintenance mode is
active. This resolves the maintenance-mode open question deferred in the
design doc.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WorkflowMonitor exposed queued, running, and failed workflow gauges but had
no metric for workflows sitting in the STOPPED state. A workflow (for
example a stopped job queue) could remain STOPPED indefinitely without any
observable signal, so operators had no way to alert on it and only noticed
after downstream detection windows elapsed and logs had already rotated.

Add a per-workflow-type StoppedWorkflowGauge that is refreshed each pipeline
cycle alongside the existing gauges, cleared in resetGauges, and registered
as an MBean attribute. Operators can now alert when any workflow of a given
type is stopped. Extend TestWorkflowMonitor to cover the new gauge on both
the accumulate and post-reset paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ry-time-metrics

Add partition recovery time metrics
…live instance guard-rail rule (linkedin#213)

* Add guard rail framework for helix-rest with min-active-replica rule

Introduce a small, extensible pre-write validation framework in helix-core
(org.apache.helix.guardrail) that lets helix-rest endpoints pre-validate a
proposed cluster mutation before ZooKeeper is updated, so unsafe operations
are rejected up front instead of surfacing later as rebalance failures.

Framework (helix-core):
- GuardrailRule: a pure, read-only check over a GuardrailContext.
- GuardrailContext: immutable, builder-based carrier (cluster, data accessor,
  target instance) that grows additively as new rule types are added.
- GuardrailPipeline: runs an endpoint's rules and aggregates violations;
  fails closed if a rule throws. Endpoints construct a pipeline with just the
  rules they need, so no central registry or per-operation dispatch is needed.
- ValidationResult / Violation: immutable, JSON-serializable verdict types.

Rule (helix-core):
- MinActiveReplicaGuardrailRule: blocks dropping an instance when doing so
  would take any hosted partition below its configured minActiveReplicas,
  delegating to InstanceValidationUtil.siblingNodesActiveReplicaCheckWithDetails.

REST wiring (helix-rest):
- AbstractHelixResource.preflight(...) supports three modes from one read-only
  pipeline: enforce (400 with verdict), dryRun=true (200 verdict, never writes,
  i.e. "simulate"), and force=true (proceed despite violations, logged).
- DELETE /clusters/{cluster}/instances/{instance} now runs the guard rail and
  accepts force and dryRun query params.

Tests:
- TestGuardrailPipeline: framework mechanics (aggregation, fail-closed,
  immutability).
- TestMinActiveReplicaGuardrailRule: rule pass/fail/no-target via a mocked
  HelixDataAccessor.
- TestPerInstanceAccessor: block (400), dryRun (200 verdict), and force-bypass
  paths for the delete endpoint.

Also adds the design doc docs/design/005-helix-rest-guard-rails.md.

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

* Delete docs/design/005-helix-rest-guard-rails.md

* Address code-review findings on the guard rail framework

Follow-up fixes from an independent review of the guard rail framework:

- GuardrailPipeline: a rule that returns null now fails closed (records a
  violation + logs), symmetric with a rule that throws. Previously a null
  result was silently treated as feasible, contradicting the fail-closed
  policy and letting a misbehaving rule open the gate.
- MinActiveReplicaGuardrailRule: catch HelixException from
  siblingNodesActiveReplicaCheckWithDetails (which scans every enabled
  resource and can throw, e.g. when an unrelated resource has no ExternalView
  yet, before it can tell whether the target instance even hosts it) and
  return an honest "could not verify minimum active replicas" violation with
  the force=true escape hatch, instead of mislabeling an evaluation failure as
  a replica shortfall.
- AbstractHelixResource.preflight: document that a dry-run verdict reflects
  only the guard rail rules, not the full feasibility of the underlying
  mutation (which may enforce its own preconditions).
- Tests: add a null-returning-rule fail-closed test to TestGuardrailPipeline;
  strengthen the force-bypass integration test to assert the request reached
  dropInstance (fails on the live participant with "is still alive") rather
  than merely returning some 400.

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

* Scope min-active-replica guard rail to hosted resources; document preflight race

Addresses review feedback on the guard rail framework.

1) MinActiveReplicaGuardrailRule previously delegated to
siblingNodesActiveReplicaCheckWithDetails, which scans every enabled
resource and throws the instant any resource lacks an ExternalView --
before it checks whether that resource is even hosted on the target
instance. The rule caught that and failed closed, so a single not-yet-placed
resource blocked DELETE for every instance in the cluster until its
ExternalView was computed.

Add a backward-compatible overload of siblingNodesActiveReplicaCheckWithDetails
that skips resources without an ExternalView (a resource with no committed
placement is not hosted on the instance and cannot be driven below its min
active replicas by dropping it). The existing 3-arg method delegates with
skip=false, so MaintenanceManagementService keeps its strict behavior. The
rule uses skip=true, scoping the block to resources actually hosted on the
instance.

2) Document in AbstractHelixResource.preflight that the verdict is computed
from a state snapshot and the cluster can change between the preflight read
and the actual mutation, so the check is a best-effort early abort rather
than a transactional gate; the mutation's own preconditions and the
controller remain the authoritative safety net.

Adds a unit test asserting a resource without an ExternalView is skipped.

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

* Replace min-active-replica instance-drop guard rail with a live-instance check

The DELETE instance guard rail previously ran MinActiveReplicaGuardrailRule,
which scanned every resource's ideal state / external view to reject a drop that
would breach min active replicas. On the actual delete path that check is both
expensive and near-silent: an instance can only be dropped once it is already
offline, at which point its at-risk partitions are skipped and the rule passes
regardless of replication state. The meaningful precondition for dropping an
instance is simply that it is no longer live.

Replace it with LiveInstanceGuardrailRule, which reads only the target's single
LIVEINSTANCES znode and blocks (or, for dryRun, reports) the drop while the
participant is still connected. This mirrors the authoritative check already in
ZKHelixAdmin.dropInstance but surfaces it as a structured pre-flight verdict and
makes dryRun truthful, at negligible ZK read cost. The admin-layer check is
retained as the enforcer for non-REST callers and to close the TOCTOU gap.

- Add LiveInstanceGuardrailRule (+ unit test)
- Wire it into PerInstanceAccessor.deleteInstance in place of the min-active rule
- Update TestPerInstanceAccessor delete guard-rail coverage
- Remove MinActiveReplicaGuardrailRule and its unit test

The shared InstanceValidationUtil.siblingNodesActiveReplicaCheckWithDetails and
MinActiveReplicaCheckResult helpers are left intact; they remain in use by
MaintenanceManagementService.

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

* Narrow guard rail data access to a read-only view

Guard rail rules are contractually pure and read-only, but GuardrailContext
handed them a full HelixDataAccessor, which also exposes setProperty /
updateProperty / removeProperty / createChildren. That let a validation rule
mutate the cluster -- a footgun the contract forbids but the type permitted.

Introduce ReadOnlyDataAccessor, a narrow interface exposing only the read
subset (getProperty, getChildNames, getChildValues, keyBuilder), and hand that
to rules instead. GuardrailContext.Builder.dataAccessor(...) still accepts a
HelixDataAccessor and wraps it via ReadOnlyDataAccessor.of(...), so REST callers
are unchanged; only rules see the narrowed type, enforcing the read-only
contract at compile time rather than by convention.

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

---------

Co-authored-by: Sarthak Jain <sarjain@linkedin.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Block adding a WAGED resource whose per-partition weight exceeds the
largest single instance's capacity in any dimension, which would make
the partition permanently unplaceable. Existing addWagedResource
validation only checks that weight keys are present, never their
magnitude, so today such a resource is accepted into ZooKeeper and only
fails later at rebalance time. This rule closes that gap by
pre-validating the mutation on the REST endpoint.

- New PartitionWeightCapacityGuardrailRule computes, per capacity
  dimension, the maximum capacity advertised by any single instance and
  fails the mutation when a partition's effective weight exceeds it.
- GuardrailContext carries the proposed ResourceConfig so rules can read
  the to-be-written weights before the object exists in ZK.
- ResourceAccessor.addResource wires the rule into the addWagedResource
  path with force/dryRun, mirroring the existing instance-drop guard rail.
- Unit tests for the rule plus an integration test for the endpoint
  (enforce, dry-run, force bypass, within-capacity happy path).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sjainit
sjainit force-pushed the sarjain/helix-rest-guardrail-weight-rule branch from 7a95001 to 73a59ef Compare August 4, 2026 10:32
@sjainit

sjainit commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Superseded by linkedin#219. The guard rail framework (linkedin#213) has merged into dev, so this rule is now a standalone upstream PR against linkedin/helix:dev (rebased onto the merged framework: the rule reads through the new read-only accessor and the min-active rule referenced here was swapped for the live-instance rule in linkedin#213). Closing this fork-internal stacked PR.

@sjainit sjainit closed this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants