Approach summary:- Use Spark Structured Streaming with Kafka (or cloud pub/sub) as source.- Rely on Spark checkpointing for exactly-once processing semantics, and employ idempotent/transactional sink semantics or external transactional coordination when sink lacks native transactions.- Maintain per-partition offsets and a durable, atomic commit of output + offsets.Pseudocode (Python-like) — exactly-once via foreachBatch + idempotent upserts and offset-stash:python
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("exactly_once_etl") \
.getOrCreate()
# 1) Read from Kafka with starting offsets and checkpointing
df = spark.readStream.format("kafka") \
.option("kafka.bootstrap.servers", "k:9092") \
.option("subscribe", "input_topic") \
.option("startingOffsets", "earliest") \
.load()
# 2) Parse and add dedup key (e.g., event_id) and event time
events = df.selectExpr("CAST(value AS STRING) as json") \
.selectExpr("from_json(json, schema) as data") \
.select("data.*") \
.withColumn("event_id", ...) \
.withColumn("event_ts", ...)
# 3) Use watermark + dropDuplicates to bound state and dedupe within time window
clean = events.withWatermark("event_ts", "1 hour") \
.dropDuplicates(["event_id"])
# 4) foreachBatch: write idempotently and atomically persist processed offsets
def foreach_batch(batch_df, batch_id):
# compute max offsets per partition from Kafka metadata (or pass offsets)
offsets = extract_offsets(batch_df) # map topic-partition -> offset
# write batch to sink using idempotent upsert (primary key = event_id)
# Example: write to transactional DB using MERGE/UPSERT
batch_df.write \
.format("jdbc") \
.option("url", jdbc_url) \
.option("dbtable", "target_table") \
.option("isolationLevel","SERIALIZABLE") \
.mode("append_or_upsert") \
.save()
# atomically persist offsets in a durable store (e.g., offsets table, ZK, or transactional DB)
persist_offsets_atomically(offsets, batch_id)
clean.writeStream \
.foreachBatch(foreach_batch) \
.option("checkpointLocation", "/mnt/checkpoints/exactly_once_etl") \
.start()
Key concepts and reasoning:- Checkpointing: Spark stores progress and state to checkpointLocation so re-start resumes without reprocessing already-committed micro-batches.- Deduplication: dropDuplicates + watermark avoids duplicate event delivery within bounded lateness; required because upstream can redeliver.- Idempotent sink: prefer sinks supporting idempotent writes or transactions: - Databases: use MERGE/UPSERT by primary key (event_id) or transactional batch + persist offsets in same DB transaction. - Kafka sink: use Kafka producer transactions (exactly-once through Spark Kafka sink supports transactions when enabled) so writes are atomic with committed offsets. - Object storage: write atomic partitioned files with unique filenames (use deterministic path including offsets/batch_id), then commit manifest.- Atomic offset commit: Only mark offsets consumed after the output write is durably committed. Implement via: - Using sink transactions that also commit consumer offsets (Kafka transactions), OR - Persist offsets in same DB transaction as upsert, OR - Two-phase commit: write to staging, then atomically move/commit + offsets.- Checkpointing + idempotency: Checkpointing prevents Spark from reprocessing the same micro-batch but cannot guarantee external sink atomicity by itself; combine with idempotent sink ops.Edge cases & trade-offs:- Long-running dedupe state: choose watermark size carefully to balance correctness vs state size.- Exactly-once for non-transactional sinks requires careful idempotent designs and atomic offset persistence.- Latency vs consistency: larger atomic operations (transactions) can increase latency.- Failure during commit: design retry logic to be idempotent; store batch_id and offsets so downstream can detect incomplete commits.This pattern gives practical exactly-once semantics: ensure atomicity between output write and offset commit, dedupe upstream duplicates, and rely on Spark checkpointing for progress recovery.