Skip to content
Field note

How To Hot-Reload Threshold Rules In Apache Flink With Broadcast State

Static, hardcoded rules force a redeploy for every threshold change. This blog walks through the broadcast state pattern used to hot-reload rules in production, from rule schema and deletion semantics to recovery testing and observability, without restarting the job.

March 17, 2026·8 min read·Qais Qadri, Senior Software Engineer/ Field correspondent
How To Hot-Reload Threshold Rules In Apache Flink With Broadcast State

Introduction

In the previous blog of this series, we laid out why static, hardcoded rules were dragging on us. Every threshold tweak meant a redeploy, and every redeploy meant risk.

This blog is about what we built to fix that. It covers the first pattern we shipped, threshold detection, and the piece that actually makes it usable in production: hot-reloading rules through Flink's broadcast state.

Series intro: why real-time pattern detection matters

Threshold Detection

Threshold-based detection is the foundational layer of real-time systems protecting against risk and abuse. Traditional implementations embed thresholds directly in code or load them once at startup. Changing a single number meant a full redeploy, even when nothing else about the logic had changed.

The core question this pattern answers: can detection logic change while the system is running, without downtime or state loss?

The Naive Approach and Why It Fails

When threshold rules change frequently, embedding them in streaming job code creates an operational bottleneck. Static configuration compounds this in several ways:

  • Rule modifications demand redeployments
  • Stateful jobs must restart for trivial updates
  • Each change increases operational risk
  • Engineering teams become gatekeepers for routine adjustments

This rigidity gets worse as detection logic grows more complex, and it puts engineering in the position of approving business decisions that have nothing to do with code.

Architectural Approach: Logic as Data

To remove that friction, we need to stream the rules continuously rather than load them once at startup.

A good architecture separates the business hypothesis from the execution mechanism: business owners define which conditions matter, and engineering owns a controlled rule format, distribution path, evaluation pattern, and recovery process.

An effective design needs continuous rule arrival via a dedicated stream, versioning and observability for every rule, immediate application to incoming events, and consistent propagation across parallel tasks. That requires a dual-stream design: one channel for events, another for rules.

How This Fits the Detection Engine

The system ingests events from Kafka (events.raw), evaluates them against active rules, and emits enriched detections to output topics (events.processed). Rules stream from a separate topic (rules.active) with hot-reload capability, so a rule change never touches the events path.

Choosing the Rule Source

A durable log is usually the right source because it supports replay and auditing.

Kafka is the example used throughout this series, but the design properties that matter are independent of the product: stable rule keys for compaction or replacement, a documented serialization contract, explicit deletion semantics, replay behavior that can reconstruct active state, access controls for who can publish rules, and a dead-letter or quarantine path for invalid updates.

If rules originate in an admin interface or database instead of a log, publish them through a controlled step rather than having every task poll that system independently.

Flink's Concepts Explained

This series relies on a handful of Flink and PyFlink concepts. Use this as a quick reference if you are new to Flink.

  • MapStateDescriptor: Defines a key-value state's name and types. Flink uses it to create the runtime state. For broadcast state, every task uses the same descriptor, so each one builds an identical local map from the same rule stream.
  • Broadcast state: State updated from a broadcast stream. Every record from that stream is sent to every parallel task, and each task updates its own local copy the same way, so all copies stay logically identical without being a single shared object.
  • BroadcastProcessFunction: Handles two inputs: a normal stream (events) and a broadcast stream (rules). Implements process_broadcast_element for the rule stream and process_element for the event stream.
  • get_broadcast_state(): Returns the broadcast state map from the context. Inside process_broadcast_element this returns a writable state. Inside process_element it returns a read-only view, since only the broadcast side is allowed to mutate the map.
  • DataStream.broadcast(): Converts a stream into a broadcast view tied to a state descriptor.
  • connect() and process(): Creates a two-input stream and runs a process function with separate callbacks for each input.
  • Kafka source (from_source): The PyFlink Kafka consumer that creates an unbounded data stream, used for both the events topic and the rules topic.

Threshold Pattern Structure

A threshold rule is the simplest rule type in this pattern.

Given an event, it checks whether specific fields satisfy a fixed set of comparison conditions, equals, greater than, less than, and so on, against constant values. It is stateless: each event is evaluated entirely on its own, with no memory of prior events.

That is what makes it the right starting point before introducing keyed state for patterns like velocity or correlation.

Rule Anatomy

Every rule shares a common structure. The core fields:

  • rule_id: a stable, unique identifier
  • version: the rule's own revision, bumped on every change, which is what triggers a hot reload
  • rule_type: determines evaluation logic (threshold, velocity, correlation)
  • source_topic: the Kafka topic this rule applies to

A few fields worth adding once rules are running in production, beyond the basics:

  • A schema or contract version, separate from the rule's own version, so the evaluator can tell whether it understands the payload shape at all
  • A revision or source sequence number, so conflicting or out-of-order updates can be resolved by something more reliable than arrival time
  • Scope, such as tenant, account, region, or event type, if rules need to apply narrower than everything on this topic
  • Effective and expiration timestamps, for time-bound rules
  • An enabled or deleted status, separate from simply omitting the rule
  • Audit metadata describing who or what published the update

Optional fields also include name, description, and tags. Threshold rules specifically include a conditions block, a list of field conditions combined with AND logic.

Rule Example

json
{
  "rule_id": "purchase_threshold_001",
  "version": "1.0",
  "rule_type": "threshold",
  "source_topic": "events.raw",
  "conditions": [
    {"field": "type", "operator": "==", "value": "purchase"},
    {"field": "value", "operator": ">=", "value": 20}
  ]
}

Evaluation logic:

  • Event {"type": "purchase", "value": 25} matches (both conditions true)
  • Event {"type": "purchase", "value": 10} does not match (value below threshold)
  • Event {"type": "view", "value": 100} does not match (wrong type)

When an event matches, the system emits enriched JSON containing the original payload plus processed, rule_id, rule_version, rule_type, and an optional rule_name.

Implementation: Broadcast State and Rule Logic

The BroadcastProcessFunction handles both rule updates and event evaluation. When a rule arrives, it is validated and stored via ctx.get_broadcast_state(descriptor). When an event arrives, the function reads that same state and applies the conditions.

python
rule_state_descriptor = MapStateDescriptor(
    "threshold-rules",
    Types.STRING(),
    Types.STRING(),  # JSON-encoded rule payload
)

broadcast_rules = rules_stream.broadcast(rule_state_descriptor)

matches = events_stream.connect(broadcast_rules).process(
    RulesBroadcastFunction(rule_state_descriptor)
)
python
class RulesBroadcastFunction(BroadcastProcessFunction):

    def process_broadcast_element(self, rule_json, ctx, out):
        rule = normalize_rule(rule_json)
        state = ctx.get_broadcast_state(rule_state_descriptor)
        if not rule.get("enabled", True):
            state.remove(rule["rule_id"])
        else:
            state.put(rule["rule_id"], json.dumps(rule))

    def process_element(self, event, ctx, out):
        state = ctx.get_broadcast_state(rule_state_descriptor)
        for rule_id, rule_json in state.items():
            rule = json.loads(rule_json)
            if rule["rule_type"] != "threshold":
                continue
            if all(evaluate_condition(event, c) for c in rule["conditions"]):
                out.collect(enrich(event, rule))

Treat this as an implementation outline, not copy-ready production code.

Serialization, error handling, and state compatibility should match the Flink release and deployment environment in use. get_field_value() reads event fields, including nested paths like user.id. evaluate_condition() compares values with numeric coercion for operators like greater than and less than. normalize_rule() validates required fields and non-empty conditions before a rule ever reaches broadcast state.

End-to-End Flow for Threshold

  • Events stream in from Kafka (events.raw)
  • Rules stream from Kafka (rules.active) as an unbounded feed
  • The rules stream is broadcast, tied to the rule state descriptor
  • Events and the broadcast rules connect through a two-input process function
  • For each event, process_element() reads broadcast state, iterates threshold rules, evaluates conditions, and yields enriched JSON for matches
  • Output streams from threshold, velocity, and correlation operators are unioned, mapped to JSON strings, and written to the Kafka sink

Rule Reload Mechanism

A reload means subsequent events see the updated rule without a job restart:

  • A new or updated rule is produced to rules.active (same rule_id, modified conditions)
  • The Kafka source reads the record and emits it on the rules stream
  • The stream is broadcast, so Flink sends it to every parallel task instance
  • Each instance runs process_broadcast_element(), which parses the JSON, validates it, retrieves broadcast state, and puts or removes the rule
  • The next incoming event triggers process_element(), which reads the state and sees the updated rule immediately

No restart required. A reload is a Kafka message, broadcast to every task, each task updates its local state map, and the next event uses the new definition.

Plan for Rule Deletion and Expiration

Removing a rule from the source system does not automatically remove its entry from broadcast state. Publish an explicit deletion marker, or use the enabled or deleted status the evaluator already understands.

Time-bound rules need the same clarity. Decide whether expiration is based on event time, processing time, or a control-plane decision, and do not let each task infer expiration from its own local clock if consistent evaluation matters across tasks.

Keep Broadcast Updates Deterministic

Broadcast state mutation should produce the same result on every parallel task. Avoid external calls, random values, or task-local conditions while applying an update. Enrichment and approval belong before a rule enters the broadcast stream. The Flink operator should receive a self-contained, validated command it can apply deterministically, not one that depends on what else is happening on that particular task.

What Comes After Threshold Matching

A stateless comparison answers whether the current event matches a rule right now. Some alerting patterns need more than that, detecting a transition, such as an account moving from below a threshold to above it, which requires keyed state rather than a stateless check. That is the subject of the next post in this series, on the velocity pattern.

Design for Recovery and Upgrades

Broadcast state participates in Flink's managed-state lifecycle, but recovery still depends on compatible serializers, checkpoint configuration, and restorable sources. Before changing the rule schema or state descriptor, test restoration from a representative savepoint or checkpoint.

A safe rollout should verify that existing rule state restores correctly, replayed updates reconstruct the expected active rule set, deleted and disabled rules stay inactive after recovery, duplicate updates are idempotent, and older rule payloads are handled or rejected on purpose rather than by accident.

Test the Behavior, Not Only the Function

Unit tests should cover rule matching, replacement, deletion, malformed updates, and stale revisions. Integration tests should exercise checkpoint recovery, source replay, rescaling, and a rule update arriving while events are in flight.

For a threshold-crossing scenario, test this sequence: an event arrives before any applicable rule exists, a valid rule is published and becomes active, an event crosses the threshold and produces a match, a revised rule changes subsequent evaluation, a deletion marker removes the rule, and recovery reconstructs the same active-rule view.

Make Rule Behavior Observable

Hot reloading removes deployments from the rule-change path, which means the control plane needs its own visibility.

Worth capturing: accepted, rejected, and deleted rule updates; the active rule revision used for each emitted match; rule-update source lag; time between publication and application; and rule-evaluation errors.

Include the rule key and revision in downstream records. That context lets someone explain why an event matched without reconstructing operator state after the fact.

When Broadcast State is the Wrong Fit

Broadcast state works best when the active rule set is small enough to replicate to every parallel task. If rules are extremely large, highly personalized, or need remote enrichment per event, replicated state can become inefficient. In those cases, keyed rule partitioning, asynchronous enrichment, or precomputed decision data are worth exploring before defaulting to broadcast state.

What This Pattern Delivers

  • Stateless filtering via condition lists (field, operator, value)
  • Rule hot-reload without job restarts or state loss
  • Consistent application across all parallel tasks via the broadcast mechanism
  • Observable versioning through version-driven updates

The key design decision is not how to compare a value with a threshold. It is how to change business-owned rules safely while preserving deterministic processing, recoverable state, and an auditable explanation for every match.

Broadcast state provides the distribution mechanism; a production-ready implementation also needs a versioned rule contract, explicit update semantics, recovery testing, and control-plane observability.

Next Steps in the Series

Upcoming blogs cover the velocity pattern (keyed state, windows, aggregations, and side outputs for transition detection) and the correlation pattern (primary and context streams, lookback, and metrics)

Building real-time detection systems and want to talk architecture? If you are exploring Flink, Kafka, or composable data pipelines for your platform, our team at Axelerant is happy to help.

Companion brief

Bring this dispatch into a working session - one page in, scoping memo out.

Brief Foyer
Pressure-test "Field note" with a partner

If this dispatch landed, bring it into a room with us.

The argument in "How To Hot-Reload Threshold Rules In Apache Flink With Broadcast State" is ours - but every situation breaks the argument differently. Send the situation in one page and a named partner will reply inside one business day with a scoping memo: where the argument holds for you, where it bends, and what we'd actually do in the first two weeks.

One-pager · Field note

By submitting this form you acknowledge that you have read Axelerant's Privacy Policy and agree to its terms.

Replied to by a named partner within one business day.