Capstone: Fraud Detection Pipeline

Build a production-grade real-time fraud detection system using PySpark Structured Streaming, Kafka, watermarks, windowing, and Delta Lake.

Introduction

This capstone puts the entire Big Data course into practice. You will build a two-job streaming pipeline that ingests banking transactions and account security events from Kafka, applies stateful fraud detection logic with watermarked time windows, and writes results to Delta Lake for a live Streamlit dashboard. Along the way you will develop a deep understanding of how Spark watermarks and windowing actually work, which are the two concepts most commonly misunderstood in production streaming systems.

PySpark 4.1 Structured Streaming
Apache Kafka (Confluent)
Delta Lake 4.1
Streamlit Dashboard
MinIO (S3-compatible)
Docker Compose

Project Purpose

The system runs two independent Spark Structured Streaming jobs in parallel. Each job consumes a different Kafka topic, applies domain-specific logic using different windowing strategies, and writes to its own set of Delta Lake tables. A Streamlit dashboard reads the Delta tables directly using the Rust-backed deltalake library (no JVM required for reads).

Job 1: Fraud Detection

Consumes the transactions topic (100 msg/s). Enriches each micro-batch with customer and merchant reference data via broadcast joins. Applies a 5-minute tumbling window to detect velocity bursts. Scores each transaction with 6 weighted fraud rules. Writes to three Delta sinks: enriched transactions, fraud alerts, and window aggregates.

Job 2: Account Risk Scorer

Consumes the account-events topic (30 msg/s). Counts FAILED_LOGIN events using a 30-minute sliding window that advances every 5 minutes, making it sensitive to sustained credential-stuffing attacks. Weights risk scores by customer tier and upserts results to a Delta table.

System Architecture

Real-Time Fraud Detection System

Transaction ProducerAccount Producertransactions topicaccount-events topicJob 1 Fraud DetectionJob 2 Risk ScorerDelta Lake (4 tables)Streamlit Dashboard100 msg/s30 msg/sappend+upsertupsertdeltalake lib

Producers inject realistic fraud patterns; the dashboard auto-refreshes every few seconds

Delta Lake as the integration layer: Both jobs write to Delta, and the dashboard reads from Delta using the lightweight deltalake Python library (no Spark needed for reads). This separates the write concern (JVM-based Spark) from the read concern (fast Rust library), which is a common production pattern.

Deep Dive: Watermarks

A watermark answers one question: how late can an event arrive and still be included in a window? Without watermarks, Spark cannot know when a window is "complete" and would have to hold state forever, which is not feasible.

Event Time vs Processing Time

Event Time

When the event actually occurred in the real world. Stamped by the producer at the moment of the transaction. This is transaction_time in this project. It can be minutes or hours behind the current clock if a mobile device was offline.

Processing Time

When Spark actually processes the event. Always the current wall clock on the executor. Processing time is never reliable for financial aggregations because network delays and restarts shift it unpredictably.

This project uses event time exclusively for all windowing. Kafka preserves event-time order within a partition, but different partitions can interleave, so late data is expected and the watermark handles it gracefully.

How Spark Computes the Watermark

After each micro-batch, Spark applies this formula:

watermark = max(event_time seen so far) - delay_threshold

# In Job 1:
# withWatermark("transaction_time", "10 minutes")
#
# If the latest transaction_time Spark has seen is 14:30:00,
# the watermark advances to 14:20:00.
#
# Any event with transaction_time < 14:20:00 arriving in
# a FUTURE micro-batch will be silently dropped.
#
# A window covering 14:10:00 - 14:15:00 will be finalized
# and its state evicted once the watermark passes 14:15:00.
The watermark only advances forward. If a single very late event carries a timestamp far in the past, Spark ignores it for watermark tracking. The watermark is derived from the maximum event time seen, not the current event's time.

Watermark Timeline

Watermark Boundary and Late Data

14:05 tx (on time)14:22 tx (on time)14:30 tx (max seen)Watermark = 14:2014:08 tx (DROPPED)max - 10 min< watermark

Once the watermark reaches 14:20, any event with event_time before 14:20 is dropped in the next batch

Applying a Watermark in Code

from pyspark.sql import functions as F

parsed = (
    raw_kafka_df
    .select(F.from_json(F.col("value").cast("string"), TRANSACTION_SCHEMA).alias("tx"))
    .select("tx.*")
    .filter(F.col("transaction_id").isNotNull())
)

# Apply a 10-minute watermark on the event-time column.
# Spark tracks max(transaction_time) seen so far.
# The watermark boundary = max_seen - 10 minutes.
# Any event whose transaction_time falls BEFORE that boundary
# is considered "too late" and is silently dropped.
with_watermark = parsed.withWatermark("transaction_time", "10 minutes")
Choosing the delay threshold:
  • Too small (e.g. 1 minute) - events from slow mobile networks or retried Kafka producers get dropped, causing under-counting in fraud scores
  • Too large (e.g. 2 hours) - Spark holds window state in memory for much longer, increasing heap pressure and delaying when alerts are emitted
  • In this project - Job 1 uses 10 minutes (transactions rarely delay more than that), Job 2 uses 15 minutes (login events can come from slower mobile SDKs). The producers inject 8-12 minute late events to demonstrate that both thresholds accept them.

Deep Dive: Tumbling vs Sliding Windows

Windows are the mechanism Spark uses to group events by time. This project uses both window types for different fraud detection goals: tumbling windows for transaction velocity bursts, sliding windows for sustained login attack detection.

Tumbling Windows (Job 1 - Transaction Velocity)

A tumbling window has a fixed size and does not overlap. Every event belongs to exactly one window. The window "tumbles" forward by its full duration when it closes. This makes them ideal for answering "how many transactions occurred in each 5-minute block?", which is precisely the velocity-burst check.

5-Minute Tumbling Windows

14:00 - 14:05 3 tx14:05 - 14:10 7 tx14:10 - 14:15 2 tx14:15 - 14:20 1 txALERT (7 > limit 5)velocity burst

Each transaction belongs to exactly one window; the 14:05-14:10 bucket exceeds the velocity threshold

Tumbling Window Implementation (aggregations.py)

from pyspark.sql import functions as F

def transaction_velocity_window(df, window_duration="5 minutes"):
    """
    Tumbling window: non-overlapping, fixed-size time buckets.
    Each transaction belongs to exactly ONE window.
    Requires withWatermark() to have been called on df first.
    """
    return (
        df.groupBy(
            F.col("account_id"),
            F.window(F.col("transaction_time"), window_duration),
        )
        .agg(
            F.count("*").alias("tx_count"),
            F.sum("amount").alias("total_amount"),
            F.max("amount").alias("max_amount"),
        )
        .select(
            F.col("account_id"),
            F.col("window.start").alias("window_start"),
            F.col("window.end").alias("window_end"),
            F.col("tx_count"),
            F.col("total_amount"),
            F.col("max_amount"),
        )
    )

Sliding Windows (Job 2 - Login Attack Detection)

A sliding window also has a fixed size but advances by a smaller slide interval. This means windows overlap, and each event can appear in multiple windows (up to window_duration / slide_duration). With a 30-minute window and a 5-minute slide, each failed login event appears in up to 6 overlapping windows.

This is the right choice for detecting sustained attacks: a credential-stuffing attack spread over 25 minutes would be split across multiple tumbling windows (possibly never exceeding the threshold in any single one), but a sliding window captures the full pattern.

30-Min Sliding Windows with 5-Min Slide

FAILED_LOGIN @ 14:1713:55 - 14:25 (window A)14:00 - 14:30 (window B)14:05 - 14:35 (window C)14:10 - 14:40 (window D)Same event appears in all 4

A single failed login at 14:17 is counted in every overlapping window that contains 14:17

Sliding Window Implementation (aggregations.py)

from pyspark.sql import functions as F

def failed_login_sliding_window(df, window_duration="30 minutes", slide_duration="5 minutes"):
    """
    Sliding window: overlapping buckets that advance by slide_duration.
    Each event can appear in multiple windows (up to window/slide = 6 windows here).
    Captures sustained attack patterns that tumbling windows would split across boundaries.
    Requires withWatermark() to have been called on df first.
    """
    failed_logins = df.filter(F.col("event_type") == "FAILED_LOGIN")

    return (
        failed_logins.groupBy(
            F.col("account_id"),
            F.window(F.col("event_time"), window_duration, slide_duration),
        )
        .agg(F.count("*").alias("failed_login_count"))
        .select(
            F.col("account_id"),
            F.col("window.start").alias("window_start"),
            F.col("window.end").alias("window_end"),
            F.col("failed_login_count"),
        )
    )
Critical rule: watermark before windowing

You must call .withWatermark() before the .groupBy(window(...))call. Without a watermark, Spark holds all window state indefinitely and throws a runtime error when you try to use append output mode with aggregations. The watermark is what tells Spark it is safe to finalize and evict a window from memory.

Output Modes: append vs update

Spark Structured Streaming has three output modes. Choosing the wrong one causes either runtime errors or silent data duplication.

ModeWhat is emitted each micro-batchWorks with aggregations?Requires watermark?
appendOnly new rows that will never changeOnly with watermark (finalized windows)Yes, if used with windowed aggregations
updateOnly rows that changed in this batch (new or updated)Yes, windows emit partial results as they receive eventsNo (but recommended for state eviction)
completeThe full result table every batchYesNo (but state grows unbounded)

Job 1 uses append

Fraud alerts are immutable facts: once a transaction is scored and emitted, the score does not change. The 10-minute watermark guarantees that windows are fully closed before any row is appended to the Delta sink. This is safe for partitioned tables where downstream consumers must not see row updates.

Job 2 uses update

Sliding windows emit intermediate counts as more events arrive within the window duration. Using update mode emits only the windows that changed in this micro-batch (efficient). The foreachBatchhandler upserts these into Delta using DeltaTable.merge(), so previously written rows are updated in place rather than duplicated.

The foreachBatch Pattern

Both jobs use foreachBatch instead of a simple .writesink. This is the pattern to reach for whenever a single streaming job must write to multiple sinks or perform logic that requires a batch-style DataFrame API.

Job 1: foreachBatch with Multiple Sinks (fraud_detection.py)

from delta.tables import DeltaTable
from pyspark.sql.functions import col, to_date

# Static reference tables are loaded ONCE here and cached.
# Loading inside process_batch would hit the DB on every micro-batch.
customers_df = load_customers_batch(spark)
blacklist_df = load_merchant_blacklist(spark)
customers_df.cache()
blacklist_df.cache()

def process_batch(micro_batch_df, batch_id):
    if micro_batch_df.isEmpty():
        return

    # Cache the raw micro-batch so Kafka bytes are not re-read for each sink
    micro_batch_df.persist()

    # Stream-batch join: enrich each micro-batch with customer and merchant data
    enriched = enrich_with_customers(micro_batch_df, customers_df)
    enriched = enrich_with_blacklist(enriched, blacklist_df)

    # Compute 5-minute tumbling velocity counts on this batch
    velocity = transaction_velocity_window(enriched)

    # Join window counts back using time bounds so each transaction
    # matches only the window it actually falls inside.
    enriched = enriched.join(
        velocity.select(
            col("account_id").alias("_va"),
            col("window_start"),
            col("window_end"),
            col("tx_count"),
            col("total_amount").alias("window_total_amount"),
        ),
        on=(
            (col("account_id") == col("_va"))
            & (col("transaction_time") >= col("window_start"))
            & (col("transaction_time") < col("window_end"))
        ),
        how="left",
    ).drop("_va")

    # Score every row against the 6 fraud rules
    scored = apply_fraud_rules(enriched, tx_count_col="tx_count")
    scored.persist()

    # Sink 1: all enriched transactions, partitioned by date (append)
    (scored
        .withColumn("tx_date", to_date("transaction_time"))
        .write.format("delta")
        .mode("append")
        .partitionBy("tx_date")
        .option("mergeSchema", "true")
        .save(f"{DELTA_BASE_PATH}/enriched_transactions"))

    # Sink 2: only rows that triggered the fraud threshold
    (scored.filter("is_fraud_alert = true")
        .write.format("delta")
        .mode("append")
        .save(f"{DELTA_BASE_PATH}/fraud_alerts"))

    # Sink 3: window aggregates - upsert (idempotent re-runs safe)
    upsert_to_delta(f"{DELTA_BASE_PATH}/window_aggregates", velocity,
                    merge_keys=["account_id", "window_start"])
    scored.unpersist()
    micro_batch_df.unpersist()

query = (
    with_watermark
    .writeStream
    .foreachBatch(process_batch)
    .option("checkpointLocation", f"{CHECKPOINT_BASE}/fraud_detection")
    .trigger(processingTime="10 seconds")
    .outputMode("append")
    .start()
)

What is Happening Here?

  • Single read, multiple sinks - Kafka is consumed once per micro-batch. The same DataFrame is written to three different Delta paths. Without foreachBatchyou would need three separate streaming queries, each reading from Kafka independently.
  • Broadcast joins - Customer and merchant tables are small and static (relative to the stream). Loading them with F.broadcast() pins them on each executor, eliminating a full shuffle for the join.
  • persist() / unpersist() - After enrichment and scoring, scored.persist()materializes the DataFrame once so that the two subsequent Delta writes do not recompute the full pipeline from the Kafka bytes a second time.
  • Checkpointing - The checkpointLocation stores the last Kafka offsets consumed and the streaming state (watermark position, open windows) to durable storage. On restart, the job resumes from exactly where it stopped. Without a checkpoint, every restart reprocesses from the beginning.

Idempotent Upsert to Delta (delta_utils.py)

from delta.tables import DeltaTable
from pyspark.sql import DataFrame

def upsert_to_delta(delta_path: str, micro_batch_df: DataFrame, merge_keys: list[str]):
    """
    Idempotent merge: update existing rows, insert new ones.
    Safe to re-run if a micro-batch is replayed (exactly-once semantics).
    """
    spark = micro_batch_df.sparkSession
    if micro_batch_df.isEmpty():
        return

    if not DeltaTable.isDeltaTable(spark, delta_path):
        # First write creates the table
        micro_batch_df.write.format("delta").mode("overwrite").save(delta_path)
        return

    merge_condition = " AND ".join(f"t.{k} = s.{k}" for k in merge_keys)
    (DeltaTable.forPath(spark, delta_path)
        .alias("t")
        .merge(micro_batch_df.alias("s"), merge_condition)
        .whenMatchedUpdateAll()
        .whenNotMatchedInsertAll()
        .execute())

Fraud Scoring Logic

Each transaction is evaluated against 6 independent rules. Each rule produces a binary flag (0 or 1) multiplied by a weight. The weights reflect the relative severity of each indicator. The final fraud_score is the weighted sum capped at 1.0, and an alert fires when the score reaches 0.35.

RuleWeightConditionRationale
flag_high_amount0.30amount > $10,000Large transfers are disproportionately targeted by fraudsters
flag_blacklisted_merchant0.40merchant on blacklistHighest weight: known bad actor, near-certain fraud
flag_new_account0.10account age < 30 daysNew accounts are higher risk but not conclusive on their own
flag_flagged_account0.35ops team manually flaggedHuman-in-the-loop signal carries high weight
flag_international_mismatch0.20cross-border AND country mismatchTransaction in a country different from the account's registered home
flag_velocity_burst0.50> 5 tx in the 5-min windowHighest weight: card testing and account takeover both exhibit this pattern

Fraud Rules Implementation (fraud_rules.py)

from pyspark.sql import functions as F

HIGH_AMOUNT_THRESHOLD = 10_000.0
VELOCITY_TX_LIMIT     = 5
NEW_ACCOUNT_AGE_DAYS  = 30
FRAUD_SCORE_THRESHOLD = 0.35

def apply_fraud_rules(df, tx_count_col=None):
    df = df.withColumn("flag_high_amount",
        F.when(F.col("amount") > HIGH_AMOUNT_THRESHOLD, F.lit(1)).otherwise(F.lit(0)))

    df = df.withColumn("flag_blacklisted_merchant",
        F.col("is_blacklisted").cast("int"))

    df = df.withColumn("flag_new_account",
        F.when(F.col("account_age_days") < NEW_ACCOUNT_AGE_DAYS, F.lit(1)).otherwise(F.lit(0)))

    df = df.withColumn("flag_flagged_account",
        F.col("is_flagged").cast("int"))

    df = df.withColumn("flag_international_mismatch",
        F.when(
            (F.col("is_international") == 1) &
            (F.col("country") != F.col("customer_country")),
            F.lit(1),
        ).otherwise(F.lit(0)))

    if tx_count_col and tx_count_col in df.columns:
        df = df.withColumn("flag_velocity_burst",
            F.when(F.col(tx_count_col) > VELOCITY_TX_LIMIT, F.lit(1)).otherwise(F.lit(0)))
    else:
        df = df.withColumn("flag_velocity_burst", F.lit(0))

    raw_score = (
        F.col("flag_high_amount")              * 0.30
        + F.col("flag_blacklisted_merchant")   * 0.40
        + F.col("flag_new_account")            * 0.10
        + F.col("flag_flagged_account")        * 0.35
        + F.col("flag_international_mismatch") * 0.20
        + F.col("flag_velocity_burst")         * 0.50
    )
    df = df.withColumn("fraud_score", F.least(raw_score.cast("double"), F.lit(1.0)))
    df = df.withColumn("is_fraud_alert", F.col("fraud_score") >= FRAUD_SCORE_THRESHOLD)
    return df
Score examples: A transaction against a blacklisted merchant (0.40) from a new account (0.10) gets a score of 0.50 and triggers an alert. A high-amount international mismatch (0.30 + 0.20 = 0.50) also alerts. A single high-amount transaction from a trusted, established account (0.30 alone) does not alert, avoiding false positives on normal large wire transfers.

Job 2: Account Risk Scoring (account_risk.py)

Job 2 shows the pattern differences that come from using a sliding window and outputMode("update"). Risk scores are weighted by customer tier because a PREMIUM or BUSINESS customer failing 5 logins is less suspicious than a STANDARD account doing the same (PREMIUM/BUSINESS customers are more active users and have broader access patterns).

Account Risk Scorer - Essential Pipeline (account_risk.py)

# Job 2: Account Risk Scorer (account_risk.py)
# Uses a SLIDING window (30 min / 5 min slide) + outputMode("update")

account_events = (
    spark.readStream.format("kafka")
    .option("kafka.bootstrap.servers", KAFKA_BROKERS)
    .option("subscribe", ACCOUNT_EVENTS_TOPIC)
    .load()
    .select(F.from_json(F.col("value").cast("string"), ACCOUNT_EVENT_SCHEMA).alias("d"))
    .select("d.*")
    .withWatermark("event_time", "15 minutes")   # 15-min late-data tolerance
)

# Sliding window: each FAILED_LOGIN can appear in up to 6 overlapping windows
risk_windows = failed_login_sliding_window(account_events, "30 minutes", "5 minutes")

TIER_WEIGHTS = {"STANDARD": 0.10, "PREMIUM": 0.06, "BUSINESS": 0.04}

# Load and cache once, not inside the batch function
customers_df = load_customers_batch(spark).select("account_id", "tier", "is_flagged")
customers_df.cache()

def _add_tier_weights(df):
    """Map customer tier to a risk weight multiplier column."""
    return df.withColumn(
        "tier_weight",
        F.when(F.col("tier") == "STANDARD", F.lit(0.10))
         .when(F.col("tier") == "PREMIUM",  F.lit(0.06))
         .when(F.col("tier") == "BUSINESS", F.lit(0.04))
         .otherwise(F.lit(0.10)),
    )

def score_batch(micro_batch_df, batch_id):
    if micro_batch_df.isEmpty():
        return

    micro_batch_df.persist()

    enriched = micro_batch_df.join(
        F.broadcast(customers_df), on="account_id", how="left"
    )

    scored = (
        _add_tier_weights(enriched)
        .withColumn("risk_score",
            F.least(
                (F.col("failed_login_count") * F.col("tier_weight")).cast("double"),
                F.lit(1.0),
            ))
        .withColumn("is_high_risk", F.col("risk_score") >= 0.5)
    )
    upsert_to_delta(f"{DELTA_BASE_PATH}/account_risk_scores", scored,
                    merge_keys=["account_id", "window_start"])
    micro_batch_df.unpersist()

query = (
    risk_windows
    .writeStream
    .foreachBatch(score_batch)
    .option("checkpointLocation", f"{CHECKPOINT_BASE}/account_risk")
    .trigger(processingTime="30 seconds")
    .outputMode("update")    # emit only windows that changed this micro-batch
    .start()
)

Project Structure and First Steps

Module Layout

pyspark-streaming-processing/
├── docker-compose.yml      # Zookeeper, Kafka, Kafka-UI, Postgres, MinIO, Spark
├── Makefile                # All workflow commands (see below)
├── pyproject.toml          # pyspark==4.1.1, delta-spark==4.1.0, confluent-kafka, ...
├── .env                    # KAFKA_BROKERS, DELTA_BASE_PATH, POSTGRES_DSN, ...
├── producers/
│   ├── transaction_producer.py     # 100 msg/s with injected fraud patterns
│   └── account_event_producer.py  # 30 msg/s with credential-stuffing bursts
├── src/
│   ├── config.py           # All settings read from .env
│   ├── schemas/            # PySpark StructType schemas for Kafka payloads
│   ├── transforms/
│   │   ├── enrichment.py   # Stream-batch broadcast joins
│   │   ├── fraud_rules.py  # 6-rule weighted scoring
│   │   └── aggregations.py # Tumbling + sliding window helpers
│   ├── jobs/
│   │   ├── fraud_detection.py  # Job 1 entry point
│   │   └── account_risk.py     # Job 2 entry point
│   └── utils/
│       ├── spark_session.py    # SparkSession factory (Delta + S3A config)
│       └── delta_utils.py      # load_customers_batch, upsert_to_delta
├── dashboard/
│   ├── app.py              # Streamlit: 3 tabs (Fraud, Velocity, Account Risk)
│   └── utils.py            # read_table() using deltalake lib (no JVM)
└── tests/
    ├── unit/               # Fraud rule tests, enrichment tests
    └── integration/        # Full pipeline on local Spark with fixture data

Infrastructure Services

ServiceImagePortPurpose
Zookeeperconfluentinc/cp-zookeeper2181Kafka coordination
Kafkaconfluentinc/cp-kafka9092Message broker (2 topics)
Kafka UIprovectuslabs/kafka-ui8080Topic browser and lag monitor
Postgrespostgres:165432Customer and merchant reference data
MinIOminio/minio9000/9001S3-compatible object store for Delta tables
Sparkapache/spark:4.1.18081Standalone cluster (1 master, 1 worker)

Key Environment Variables (.env)

# Kafka
KAFKA_BROKERS=localhost:9092
TRANSACTIONS_TOPIC=transactions
ACCOUNT_EVENTS_TOPIC=account-events

# Storage (local filesystem default - change to s3a:// for MinIO)
DELTA_BASE_PATH=data/delta
CHECKPOINT_BASE=data/checkpoints

# Postgres reference data
POSTGRES_DSN=jdbc:postgresql://localhost:5432/banking
POSTGRES_USER=banking
POSTGRES_PASSWORD=banking

# Fraud thresholds (override without code changes)
HIGH_AMOUNT_THRESHOLD=10000.0
VELOCITY_TX_LIMIT=5
FRAUD_SCORE_THRESHOLD=0.35

How to Run the Full Pipeline

Requires Docker, Python 3.12+, and Java 21 LTS (Java 24+ breaks Spark due to SecurityManager removal in JEP 486).

# 1. Start all infrastructure services
make up && sleep 20

# 2. Generate seed data (10 000 customers + 5 blacklisted merchants)
make init-data

# 3. Open 5 separate terminals and run these in parallel:

# Terminal A - Transaction stream (100 msg/s, fraud patterns injected)
make producer-transactions

# Terminal B - Account event stream (30 msg/s, credential-stuffing bursts)
make producer-accounts

# Terminal C - Fraud detection job (Job 1)
make job-fraud

# Terminal D - Account risk scoring job (Job 2)
make job-accounts

# Terminal E - Streamlit dashboard
make dashboard
Monitoring Endpoints
  • Kafka UI: http://localhost:8080 - watch topic lag, partition distribution, and message throughput
  • Spark UI: http://localhost:4040 - streaming tab shows watermark position, batch durations, and state store size
  • Streamlit dashboard: http://localhost:8501 - three tabs: Fraud Alerts, Transaction Velocity, Account Risk
  • MinIO console: http://localhost:9001 - browse Delta table files if using S3A storage mode

Key Takeaways

  • Watermarks bound state memory - without a watermark, Spark must hold every open window's state forever. The watermark is the contract that tells Spark when a window can safely be finalized and evicted.
  • Watermark = max(event_time) - threshold - it only advances forward. Events with an event_time before the watermark boundary are dropped, not buffered.
  • Tumbling windows for fixed-period aggregations - each event belongs to exactly one window. Use them when you want non-overlapping counts (velocity per 5-min slot).
  • Sliding windows for sustained pattern detection - each event appears in multiple overlapping windows. Use them when an attack might straddle a tumbling window boundary and evade detection.
  • Output mode rules are strict - append with windowed aggregations requires a watermark. update emits only changed windows and pairs with Delta upserts for efficient writes.
  • foreachBatch unlocks multiple sinks - one Kafka read feeds three Delta sinks in Job 1. Use persist() to avoid recomputing the enrichment pipeline for each write.
  • Broadcast joins eliminate shuffle - static reference tables (customers, blacklist) are sent to every executor once, turning what would be a full shuffle join into a local lookup.
  • Checkpointing is non-optional in production - it stores Kafka offsets and watermark state durably, enabling exactly-once semantics and safe restarts.
  • Delta + deltalake separates write and read concerns - Spark writes via JVM; the dashboard reads via the Rust-backed deltalake library with no Spark context needed. This pattern scales reads independently of the streaming jobs.