End-to-End Architecture Map
The complete Spark engine lifecycle on one responsive map — from raw code submission down to OneLake Parquet bytes. Click any node to open its technical inspector, or press Trace a query to walk the full execution sequence.
Spark Engine Internals
An interactive, factual walkthrough of how Apache Spark actually executes a job — from the driver's query plan down to the bytes an executor writes to disk — plus how Microsoft Fabric's Native Execution Engine and Efficient Scaledown change specific parts of that path. Twenty-nine sections covering the engine, Spark SQL and PySpark practice, engine choice, and the Fabric platform around them. Start with the console below: pick a workload, set the engine switches, press Run job, and click the nodes to see what each part actually does.
Cluster Architecture & The 1:1 Rule
Every Spark deployment — local, YARN, Kubernetes, or a Fabric Spark pool — shares the same three roles. What differs across platforms is only who launches the executors and how.
| Role | Responsibility |
|---|---|
| Driver |
Runs your main()/notebook code, holds the
SparkContext/SparkSession, builds the DAG, and hosts
the DAGScheduler, TaskScheduler, and MapOutputTracker (Sec 11). One per application.
|
| Cluster manager | Negotiates resources for executors on the driver's behalf — YARN, Kubernetes, Spark's own Standalone manager, or (on Fabric) the platform's own pool/autoscale manager. The driver never talks to hardware directly. |
| Executor | A JVM process that runs tasks and holds data in memory/disk for the life of the application (or until decommissioned — Sec 13). One per node in Fabric (always 1:1); potentially several per node on classic YARN. |
Deployment modes
The distinction that actually matters day to day is where the driver runs, not which cluster manager is in use:
- Client mode — the driver runs on the machine that submitted the job (your laptop, a notebook kernel). Convenient for interactive work; the job dies if that machine disconnects.
- Cluster mode — the driver itself runs inside the cluster, as just another managed process. Standard for production batch jobs; survives the submitting client disconnecting.
Fabric notebooks run the driver inside the Spark pool itself — architecturally closer to cluster mode, with the notebook UI acting as a thin client attached to a live remote session.
Fabric specifically: the 1:1 node→executor rule
This is a real structural difference from classic YARN Spark, and it changes how executor sizing works (see the worked comparison in Sec 15). In Fabric, one Spark pool node hosts exactly one executor — there's no "how many executors should I pack onto this node" decision the way there is on YARN. One node in the pool is always reserved for the driver; the rest become executors 1:1. The only exception is a single-node pool, where the driver and the sole executor share that one node's resources.
spark.executor.cores packing math to do the way the classic Cloudera "5
cores per executor, N executors per node" guidance requires on YARN — see
Sec 15 for the exact node-size table.
Job · Stage · Task Execution Model
Spark is lazy: transformations (select, filter,
join) only build up a logical plan. Nothing executes until an
action (write, collect, count) forces
it. That action produces exactly one job.
| Unit | Created by | Boundary |
|---|---|---|
| Job | One per action | Ends when the action's result is produced. |
| Stage | DAGScheduler | A new stage begins at every shuffle boundary — i.e. every wide transformation. A job with no wide transformations is one stage. |
| Task | TaskScheduler | One task per partition, per stage. A stage with 200 partitions launches 200 tasks, scheduled onto executor cores as they free up. |
Narrow vs. wide transformations
This distinction is the single most load-bearing concept in the entire execution model — it's the difference between "free" and "expensive":
-
Narrow —
map,filter,select,union. Each output partition depends on exactly one input partition. No data crosses the network; Spark can pipeline these into a single stage. -
Wide —
groupBy,join,distinct,repartition. An output partition can depend on every input partition, because rows with the same key can start anywhere. This forces a shuffle (Sec 11) and a new stage.
DAGScheduler vs. TaskScheduler
Two schedulers, two different jobs, both living on the driver:
- DAGScheduler — stage-level. Walks the RDD/plan dependency graph backwards from the action, cuts it into stages at shuffle boundaries, and submits stages in dependency order (a stage can't start until every stage it depends on has finished writing its output).
- TaskScheduler — task-level. Takes one stage's worth of tasks and assigns them to executor cores, handling retries, speculative execution, and locality preference (run a task on the executor that already holds its input data, when possible).
FetchFailedException. The DAGScheduler's response isn't to
retry that one task — it recomputes the entire owning ShuffleMapStage. This is
exactly the cost Efficient Scaledown's Shuffle Migration and Remote Shuffle Manager
exist to avoid (Sec 13).
Narrow vs. Wide Transformations: Comprehensive Matrix
| Category | Spark Transformations | Data Movement & Cost | Internal Execution Behavior |
|---|---|---|---|
| Narrow (Free) |
select(), filter(), map(),
flatMap(), withColumn(), union(),
coalesce(decrease)
|
Zero network transfer. Data stays within the executor JVM memory arena. | Tungsten fuses operations via Whole-Stage Code Generation (Sec 05) into a tight Java loop executing in CPU registers. |
| Wide (Expensive) |
groupBy(), join(), distinct(),
repartition(), cube(), rollup(),
sort()
|
All-to-all network shuffle. Every partition potentially writes blocks for every reducer. | Forces a Stage Boundary cut by DAGScheduler. Spills to local NVMe SSD / RSM (Sec 11) if working memory fraction is exceeded. |
Data Locality Levels: How TaskScheduler Places Work
TaskScheduler tries to achieve the tightest data locality possible, waiting a configurable delay before dropping to a looser level:
| Locality Level | Where Data Lives | Performance | Fabric Production Reality |
|---|---|---|---|
PROCESS_LOCAL |
In the same JVM memory where the task is running. | Fastest (Sub-millisecond) |
Achieved when reading from Spark in-memory Cache (.cache()) on an
already active executor.
|
NODE_LOCAL |
On the same physical VM / node (e.g. local NVMe SSD). | Fast (No network) | Occurs when reading local shuffle map outputs written during earlier stages on the same node. |
RACK_LOCAL |
On another node in the same rack / availability zone. | Medium (Intra-rack network) | Common in large multi-node clusters during shuffle fetch over Azure high-speed interconnect. |
ANY |
Anywhere across network or remote object storage. | Slowest (Remote ABFS read) | Standard baseline for all initial OneLake table scans (ABFS protocol over HTTPS). |
Catalyst Optimizer & Multi-Engine Execution Architecture
Every SQL query and DataFrame transformation in Apache Spark passes through the Catalyst Optimizer before execution. Catalyst is an extensible relational query compiler written in Scala, structured around tree transformations and rule batches. In Microsoft Fabric, Catalyst compiles queries into physical operator trees that dispatch to either Tungsten Whole-Stage Code Generation (JVM) or the Native Execution Engine (NEE / Meta Velox C++ SIMD), dynamically guided at runtime by Adaptive Query Execution (AQE).
The Four Catalyst Compilation Phases
The parser converts your text query or API chain into an Unresolved Logical Plan — an operator tree where relations (table names) and expressions (column names) are purely syntactic strings:
-
Spark SQL Path: Uses an ANTLR4-generated lexer and parser
(
SqlBaseParser.g4). The visitor pattern inAstBuilderturns SQL grammar tokens into logical operator nodes (e.g.,UnresolvedRelation,UnresolvedAttribute). -
PySpark DataFrame API Path: Skips ANTLR4 text parsing entirely.
Python API calls (e.g.
df.filter(...).select(...)) invoke Py4J bridge methods that directly instantiate Catalyst's ScalaLogicalPlannodes (Filter,Project). -
Unresolved State: Column names like
amountand table names likeordershave not yet been checked against any catalog or schema. Typographical errors exist silently in this phase.
The Analyzer resolves unresolved attributes against the active catalog (e.g. Fabric Lakehouse metastore, Hive Metastore, or session temporary views):
-
Catalog Lookup:
SessionCatalogresolves table names to real physical data sources (Parquet, Delta Lake tables, Lakehouse files) and binds schemas. -
Attribute ID Binding: Strings become typed
AttributeReferenceinstances with unique monotonic expression IDs (e.g.,amountbecomesamount#4: double,cust_idbecomescust_id#3: bigint). -
Type Coercion & Function Resolution: Built-in functions are
resolved. Implicit type casting is inserted where compatible (e.g.
TINYINT + INT → INT). -
Fast Failure: Missing tables or invalid column names throw
AnalysisExceptionhere — immediately, before cluster resources or disk I/O are touched.
The Optimizer runs standard rule batches to restructure the logical plan into its most efficient mathematical form, independent of cluster hardware:
-
Predicate Pushdown (
PushDownPredicates): Filters are moved down the operator tree past joins, projects, and window functions to sit directly atop the data source. Parquet and Delta Lake readers evaluate these filters inside file metadata statistics (row group min/max and Delta transaction log stats), skipping non-matching files before disk reads. -
Column Pruning (
ColumnPruning): Columns that are never referenced in downstream projections, joins, or aggregations are eliminated immediately at scan time, reducing I/O and deserialization. -
Constant Folding & Simplification (
FoldConstants,BooleanSimplification): Expressions like100 * (1 + 0.20)are evaluated at compile time to120.0. Redundant clauses likecol = 5 AND TRUEcollapse tocol = 5. -
Outer Join Elimination (
EliminateOuterJoin): ALEFT OUTER JOINis rewritten into anINNER JOINif a downstream filter rejects null values on the right-hand table. -
Subquery Decorrelation: Correlated scalar subqueries (
WHERE x IN (SELECT ...)) are rewritten into semi-joins or left outer joins. -
Cost-Based Optimizer (CBO): When table statistics exist (
ANALYZE TABLE COMPUTE STATISTICS), CBO can reorder multi-table joins to minimize intermediate cardinality. Both gates are off by default in Spark 3.5 and 4.x, and Fabric does not change that: join reordering requiresspark.sql.cbo.enabled=trueandspark.sql.cbo.joinReorder.enabled=true. With the defaults left alone,ANALYZE TABLEstatistics still improve broadcast-size estimation and filter selectivity, but no join reordering happens — a common reason teams collect statistics and see no plan change. Both keys areSQLConfentries and are runtime-mutable (Sec 14) — settable withspark.conf.seton a live session. In practice AQE (Sec 06) delivers most of the same benefit from real runtime statistics without anyANALYZEmaintenance to keep current.
The SparkPlanner translates the single optimized logical plan into one or more candidate physical plans (SparkPlans) using concrete execution algorithms, choosing the lowest-cost option:
-
Join Strategy Selection: Determines whether to execute a
BroadcastHashJoin,ShuffleHashJoin,SortMergeJoin, orBroadcastNestedLoopJoinbased on table size estimates and join keys. -
Aggregation Selection: Chooses between
HashAggregateExec(two-phase partial/final aggregation using off-heap hash tables) andSortAggregateExec(fallback when keys are unhashable). -
Physical Rule Execution:
EnsureRequirementsinjectsExchange(network shuffles) andSortExecnodes to fulfill required child partitionings and orderings;CollapseCodegenStagesclusters adjacent operators into single Whole-Stage CodeGen stages.
Physical Join Strategy Selection Hierarchy
Catalyst uses a deterministic priority tree to select the physical join algorithm. Understanding this hierarchy allows you to predict whether Spark will broadcast, shuffle, or sort your data:
Interactive Query Progression Explorer (Simple to Complex)
Explore how Catalyst, Tungsten, and NEE compile and execute queries across increasing levels of complexity. Each level provides side-by-side runnable PySpark DataFrame code and Spark SQL query syntax, alongside the exact plan evolution at each stage:
from pyspark.sql import functions as F
# Simple filter + constant arithmetic + projection
df_simple = (
spark.read.table("silver.orders")
.filter(
(F.col("status") == "COMPLETED") &
(F.col("amount") * (1 + 0.20) > 120)
)
.select(
F.col("order_id"),
F.col("cust_id"),
(F.col("amount") * 1.20).alias("amount_with_tax")
)
)
df_simple.explain(mode="formatted")
SELECT
order_id,
cust_id,
amount * 1.20 AS amount_with_tax
FROM silver.orders
WHERE status = 'COMPLETED'
AND amount * (1 + 0.20) > 120;
'Project [unresolvedalias('order_id), unresolvedalias('cust_id), ('amount * 1.20) AS amount_with_tax#0]
+- 'Filter (('status = COMPLETED) AND (('amount * (1 + 0.20)) > 120))
+- 'UnresolvedRelation [silver, orders], [], false
Parser Action: Column names and mathematical operations are unresolved
string symbols. (1 + 0.20) remains unfactored.
Project [order_id#1: bigint, cust_id#2: bigint, (amount#3: double * 1.2) AS amount_with_tax#4: double]
+- Filter ((status#5: string = 'COMPLETED') AND ((amount#3: double * (1.0 + 0.2)) > 120.0))
+- Relation silver.orders[order_id#1,cust_id#2,amount#3,order_date#4,status#5,notes#6] parquet
Analyzer Action: Catalog binds silver.orders, assigns
expression IDs (order_id#1, amount#3), validates data
types.
Project [order_id#1, cust_id#2, (amount#3 * 1.2) AS amount_with_tax#4]
+- Filter ((isnotnull(amount#3) AND isnotnull(status#5)) AND ((status#5 = 'COMPLETED') AND ((amount#3 * 1.2) > 120.0)))
+- Relation silver.orders[order_id#1,cust_id#2,amount#3,status#5] parquet // notes & order_date PRUNED
Optimizer Action: FoldConstants folded
(1.0 + 0.2) → 1.2; ColumnPruning dropped
notes and order_date;
NullPropagation injected isnotnull() safeguards.
*(1) Project [order_id#1, cust_id#2, (amount#3 * 1.2) AS amount_with_tax#4]
+- *(1) Filter ((isnotnull(status#5) AND (status#5 = 'COMPLETED')) AND ((amount#3 * 1.2) > 120.0))
+- *(1) FileScan parquet silver.orders[order_id#1,cust_id#2,amount#3,status#5]
Batched: true, DataFilters: [isnotnull(status#5), (status#5 = 'COMPLETED')],
PushedFilters: [IsNotNull(status), EqualTo(status,COMPLETED)]
Physical Planner Action: Selected vectorized Parquet batch scan. Injected
PushedFilters directly into the Parquet reader footer reader.
Clustered all 3 operators into *(1) WholeStageCodegen stage.
VeloxColumnarToRowExec
+- ProjectExecTransformer [order_id#1, cust_id#2, (amount#3 * 1.2) AS amount_with_tax#4]
+- FilterExecTransformer ((status#5 = 'COMPLETED') AND ((amount#3 * 1.2) > 120.0))
+- NativeFileScanTransformer parquet silver.orders[order_id#1,cust_id#2,amount#3,status#5]
Native Execution Engine (NEE): Gluten replaces operators with
*Transformer nodes. Velox reads column chunks directly into C++
SIMD vector registers (AVX-512) in 1024-row batches with zero JVM row object
allocation.
HAVING revenue threshold.
from pyspark.sql import functions as F
df_orders = spark.read.table("silver.orders") \
.filter(F.col("order_date") >= "2026-01-01")
df_cust = spark.read.table("silver.customers") \
.filter(F.col("tier") == "PLATINUM")
df_med = (
df_orders.join(df_cust, "cust_id", "inner")
.groupBy(
df_cust.region,
F.date_trunc("month", df_orders.order_date).alias("order_month")
)
.agg(
F.count("order_id").alias("total_orders"),
F.sum("amount").alias("total_revenue")
)
.filter(F.col("total_revenue") > 10000)
)
df_med.explain(mode="formatted")
SELECT
c.region,
date_trunc('month', o.order_date) AS order_month,
COUNT(o.order_id) AS total_orders,
SUM(o.amount) AS total_revenue
FROM silver.orders o
JOIN silver.customers c ON o.cust_id = c.cust_id
WHERE o.order_date >= '2026-01-01'
AND c.tier = 'PLATINUM'
GROUP BY c.region, date_trunc('month', o.order_date)
HAVING SUM(o.amount) > 10000;
Filter (total_revenue#10 > 10000.0)
+- Aggregate [region#21, date_trunc('month', order_date#3)], [region#21, date_trunc('month', order_date#3) AS order_month#8, count(order_id#1) AS total_orders#9L, sum(amount#2) AS total_revenue#10]
+- Join Inner, (cust_id#4 = cust_id#20)
:- Filter (order_date#3 >= 2026-01-01)
: +- Relation silver.orders[order_id#1,amount#2,order_date#3,cust_id#4] parquet
+- Filter (tier#22 = 'PLATINUM')
+- Relation silver.customers[cust_id#20,region#21,tier#22] parquet
Filter (total_revenue#10 > 10000.0)
+- Aggregate [region#21, _date_trunc_month#25], [region#21, _date_trunc_month#25 AS order_month#8, count(order_id#1) AS total_orders#9L, sum(amount#2) AS total_revenue#10]
+- Project [order_id#1, amount#2, region#21, date_trunc('month', order_date#3) AS _date_trunc_month#25]
+- Join Inner, (cust_id#4 = cust_id#20)
:- Relation silver.orders[order_id#1,amount#2,order_date#3,cust_id#4] parquet // PartitionFilters: [order_date >= 2026-01-01]
+- Filter (tier#22 = 'PLATINUM')
+- Relation silver.customers[cust_id#20,region#21,tier#22] parquet // PushedFilters: [EqualTo(tier,PLATINUM)]
Optimization Details: PartitionFilters prunes 90% of order
partitions in Delta metadata. PushedFilters eliminates non-PLATINUM
customer records during Parquet scan.
AdaptiveSparkPlan isFinalPlan=false
+- *(3) Filter (total_revenue#10 > 10000.0)
+- *(3) HashAggregate [region#21, _date_trunc_month#25], [sum(amount#2), count(order_id#1)] // FINAL AGGREGATE
+- AQEShuffleRead coalesced
+- Exchange hashpartitioning(region#21, _date_trunc_month#25, 200)
+- *(2) HashAggregate [region#21, _date_trunc_month#25], [partial_sum(amount#2), partial_count(order_id#1)] // PARTIAL AGGREGATE
+- *(2) Project [order_id#1, amount#2, region#21, date_trunc('month', order_date#3) AS _date_trunc_month#25]
+- *(2) BroadcastHashJoin [cust_id#4], [cust_id#20], Inner, BuildRight
:- *(2) FileScan parquet silver.orders[order_id#1,amount#2,order_date#3,cust_id#4]
+- BroadcastExchange IdentityBroadcastMode
+- *(1) Filter (tier#22 = 'PLATINUM')
+- *(1) FileScan parquet silver.customers[cust_id#20,region#21,tier#22]
Physical Execution Architecture:
1. BroadcastHashJoin: Filtered Platinum customers table (<10MB) is
broadcasted via BroadcastExchange, eliminating all shuffle on the
multi-GB orders table.
2. Two-Phase HashAggregate: Mapper stage performs
partial_sum and partial_count locally before
shuffling, reducing network payload by >95%. Reducer stage performs final
aggregation and coalesces partitions under AQE.
ROW_NUMBER() OVER (...)), skew join
key mitigation, and Native Execution Engine (NEE) vectorized evaluation.
from pyspark.sql import Window, functions as F
# 1. Base enriched dataset
df_base = (
spark.read.table("silver.orders")
.join(spark.read.table("silver.customers"), "cust_id")
.filter(F.col("status") == "DELIVERED")
)
# 2. Window ranking per customer segment
w_segment = Window.partitionBy("segment").orderBy(F.col("amount").desc())
df_complex = (
df_base
.withColumn("rank_in_segment", F.row_number().over(w_segment))
.filter(F.col("rank_in_segment") <= 3)
.select("segment", "rank_in_segment", "order_id", "cust_id", "amount")
)
df_complex.explain(mode="formatted")
WITH DeliveredOrders AS (
SELECT
o.order_id,
o.cust_id,
c.segment,
o.amount,
ROW_NUMBER() OVER (
PARTITION BY c.segment
ORDER BY o.amount DESC
) AS rank_in_segment
FROM silver.orders o
JOIN silver.customers c ON o.cust_id = c.cust_id
WHERE o.status = 'DELIVERED'
)
SELECT segment, rank_in_segment, order_id, cust_id, amount
FROM DeliveredOrders
WHERE rank_in_segment <= 3;
AdaptiveSparkPlan isFinalPlan=true
+- *(3) Filter (rank_in_segment#5 <= 3)
+- *(3) Window [row_number() windowspecdefinition(segment#21, amount#2 DESC NULLS LAST, specifiedwindowframe(RowFrame, unboundedpreceding$, currentrow())) AS rank_in_segment#5], [segment#21], amount#2 DESC NULLS LAST], [segment#21]
+- *(3) Sort [segment#21 ASC NULLS FIRST, amount#2 DESC NULLS LAST], false, 0
+- AQEShuffleRead coalesced
+- Exchange hashpartitioning(segment#21, 200)
+- *(2) SortMergeJoin [cust_id#4], [cust_id#20], Inner (skew=true)
:- *(2) Sort [cust_id#4 ASC NULLS FIRST]
: +- AQEShuffleRead (skewPartitionSplits: [P0_1, P0_2])
: +- Exchange hashpartitioning(cust_id#4, 200)
: +- *(1) Filter (status#3 = 'DELIVERED')
: +- *(1) FileScan parquet silver.orders
+- *(2) Sort [cust_id#20 ASC NULLS FIRST]
+- Exchange hashpartitioning(cust_id#20, 200)
+- *(1) FileScan parquet silver.customers
Complex Execution Mechanics:
1. CTE Inlining & Pruning: The Common Table Expression is flattened
into a single pipeline without materialization overhead.
2. AQE Skew Join Splitting: Skewed customer keys are detected at stage
boundary; partition P0 is dynamically split into sub-tasks
(P0_1, P0_2), replicating the matching customer
partition and preventing straggler executor tasks.
3. WindowExec: Sorting and ranking occur in-memory using Tungsten binary
row comparisons.
VeloxColumnarToRowExec
+- FilterExecTransformer (rank_in_segment#5 <= 3)
+- WindowExecTransformer [row_number() over segment]
+- SortExecTransformer [segment#21, amount#2 DESC]
+- ColumnarExchange hashpartitioning(segment#21)
+- ShuffledHashJoinExecTransformer [cust_id#4], [cust_id#20]
:- NativeFileScanTransformer parquet silver.orders
+- NativeFileScanTransformer parquet silver.customers
NEE Native Advantage: On Fabric Runtime 2.0 with built-ins, the entire analytical chain remains 100% native in Velox C++. Vectorized window processing avoids Python UDF serialization boundaries entirely.
df.explain(mode="formatted") for a structured numbered tree with operator
detail blocks, or df.explain(mode="cost") to view estimated row counts
and data size statistics that drove Catalyst's decisions.
Tungsten Engine & Whole-Stage Code Generation
Where Catalyst decides what to execute, Project Tungsten optimizes how execution occurs on physical CPU hardware:
1. Binary In-Memory Row Format (UnsafeRow)
Tungsten stores rows as raw, word-aligned byte arrays (UnsafeRow) outside
the standard JVM heap. This eliminates Java object header bloat (16 bytes per object),
memory pointer chasing, and garbage collection overhead.
2. Cache-Aware Memory Layout & Sorting
Operations like radix sorting and hash aggregation operate directly on 8-byte binary prefixes packed into contiguous cache lines. CPUs prefetch these lines sequentially into L1/L2 hardware caches, achieving near-zero cache miss penalties.
3. Whole-Stage Code Generation (WSCG)
Spark's Janino compiler collapses entire pipelines of operators into a single tight
Java while loop. Variables are kept directly in CPU registers rather than
written to memory between operator boundaries:
// Virtual function call per row per operator
for row in scan.next():
if filter.eval(row): // virtual dispatch
out = project.eval(row) // virtual dispatch
emit(out)
// Single fused Java function per stage
while (scan.hasNext()) {
UnsafeRow row = scan.next();
if (row.getDouble(2) > 100.0) {
emit(row.getString(0), row.getDouble(2) * 1.2);
}
}
*).
Adaptive Query Execution (AQE)
Catalyst's physical plan (Sec 04) is built from statistics known before the query runs — table metadata, maybe column stats. AQE, on by default since Spark 3.2, corrects that plan during execution, using real statistics from stages that have already run.
The Five Core AQE Features
The Problem: Setting a rigid
spark.sql.shuffle.partitions=200 produces 200 tiny, sub-second
tasks on small queries (massive scheduling overhead and thousands of small
files), while on large 500GB+ tables it causes massive 2.5GB task partitions
that spill to disk or crash with OutOfMemory (OOM).
The Mechanics: Map tasks write shuffle blocks and report exact byte
sizes to the driver via MapStatus. AQE merges adjacent small
partitions into coalesced target buckets based on
spark.sql.adaptive.advisoryPartitionSizeInBytes (default 64 MB,
recommended 128 MB in Fabric).
AdaptiveSparkPlan isFinalPlan=true
+- AQEShuffleRead coalesced (200 -> 14 partitions, target 128MB)
+- Exchange hashpartitioning(customer_id#4, 200)
•
spark.sql.adaptive.coalescePartitions.enabled = true (Fabric
default: true)•
spark.sql.adaptive.advisoryPartitionSizeInBytes = 134217728 (128
MB — ideal for OneLake/Parquet)•
spark.sql.adaptive.coalescePartitions.initialPartitionNum = 1000
(Start high; let AQE coalesce down)
The Problem: Catalyst determines join strategies before execution using
static table metadata. If your query applies highly selective filters (e.g.,
WHERE order_date = '2026-08-17' AND region = 'EMEA'), Catalyst
cannot know the post-filter cardinality in advance and defensively plans an
expensive, two-sided Sort-Merge Join (shuffling both sides).
The Mechanics: AQE executes the filtered dimension side first. When
Stage 1 materializes, the driver inspects the real byte count. If
actual_size <= spark.sql.autoBroadcastJoinThreshold (default
10MB to 30MB), AQE immediately cancels the planned Sort-Merge Join and
rewrites the remaining DAG into a BroadcastHashJoin—completely
eliminating the fact-table shuffle!
// Driver Log: "AdaptiveSparkPlan: Plan changed from SortMergeJoin to BroadcastHashJoin"
+- *(2) BroadcastHashJoin [customer_id#4], [customer_id#20], Inner, BuildRight
:- *(1) FileScan parquet silver.orders (Fact Table - ZERO SHUFFLE!)
+- BroadcastExchange HashedRelationBroadcastMode (Materialized 4.2 MB)
+- *(1) Filter (date#2 = '2026-08-17')
+- *(1) FileScan parquet silver.dim_customers
The Problem: The notorious "99% of tasks finish in 5 seconds, but 1
task runs for 30 minutes" straggler problem. Caused by non-uniform key
distributions (e.g. customer_id = NULL or default placeholder
keys holding 80% of rows).
The Mechanics: AQE monitors partition statistics across shuffle map
outputs. A partition is classified as skewed if both conditions are met:
1.
partition_size > median_partition_size *
spark.sql.adaptive.skewJoin.skewedPartitionFactor
(default: 5×)
2.
partition_size >
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes
(default: 64 MB)
When detected, AQE **splits the single skewed partition into $N$
sub-partitions** (e.g., P0_1, P0_2) and replicates
the matching partition on the probe side so work is evenly distributed across
executor cores.
+- *(2) SortMergeJoin [cust_id#4], [cust_id#20], Inner (skew=true)
:- *(2) Sort [cust_id#4 ASC NULLS FIRST]
: +- AQEShuffleRead (skewPartitionSplits: [P0_1, P0_2, P0_3])
: +- Exchange hashpartitioning(cust_id#4, 200)
+- *(2) Sort [cust_id#20 ASC NULLS FIRST]
+- AQEShuffleRead (skewPartitionReplicated: [P0])
+- Exchange hashpartitioning(cust_id#20, 200)
GROUP BY aggregations! A skewed GROUP BY will still
bottleneck a single reducer task and requires manual salting.
The Problem: In elastic lakehouse pools, dynamically deallocating idle
executor VMs deletes their local SSD shuffle blocks, causing downstream tasks
to fail with catastrophic FetchFailedException storms and
triggering entire stage retries.
The Mechanics: Microsoft Fabric links AQE directly to the Remote Shuffle Manager (RSM). AQE pre-packages shuffle partition layouts during the map write phase and streams blocks directly into OneLake/Blob storage. When executors scale down, shuffle data survives safely in remote storage, allowing downstream reducers to fetch them without recomputing map stages.
The Problem: Fact tables partitioned by date/region often contain thousands of partition directories. When joining with a filtered dimension table, static Catalyst cannot prune partitions before query execution.
The Mechanics: Spark evaluates the filtered dimension subquery first, broadcasts the distinct matching partition keys, and injects them directly into the fact table's file scan reader at runtime. The scan skips 95%+ of directory paths without reading their Parquet footers.
+- FileScan parquet gold.fact_sales
PartitionFilters: [dynamicpruningexpression(date_key#1 IN subquery#25)]
ReadSchema: struct<order_id:bigint, amount:double>
| Failure Scenario | Why AQE Fails to Engage | Required Remediation Strategy |
|---|---|---|
Skewed GROUP BY Aggregation |
AQE skew splitting is strictly coded for join operators (SortMergeJoin
/ ShuffledHashJoin). Aggregations have no probe side to
replicate.
|
Manual Salting: Add a synthetic salted key
CONCAT(key, '_', FLOOR(RAND()*10)), run a 2-stage aggregation,
and aggregate the salt away in the second phase.
|
| Cartesian Products / Non-Equi Joins |
Joins without equality predicates (e.g.
ON a.start_date < b.end_date) cannot be hashed or sort-merged;
they force CartesianProduct.
|
Rewrite the join to include at least one equi-join predicate (e.g.,
ON a.org_id = b.org_id AND ...) or use range-bucketing
techniques.
|
| Narrow-Only Pipeline (No Shuffles) | AQE's optimization loop triggers only at shuffle exchange stage barriers. Pure map-only jobs (Filter → Project → Append) have no re-optimization barriers. |
Pre-tune file sizes at source using
spark.sql.files.maxPartitionBytes (default 128MB).
|
| Subqueries & Broadcast Timeouts |
If a broadcast relation build exceeds
spark.sql.broadcastTimeout (default 300s) due to GC pauses or
network choke, the query aborts.
|
Ensure driver has sufficient RAM, or explicitly disable broadcast for that
specific query with /*+ MERGE(t1) */.
|
Native Execution Engine (NEE) & Gluten
NEE is Fabric Spark's vectorized C++ execution path — built on two open-source components: Velox (a C++ database acceleration library open-sourced by Meta) and Apache Gluten (incubating; a middle layer, originated by Intel, that translates Spark's physical plan into a form Velox can execute). General availability: Fabric Runtime 1.3.
Each row is a separate JVM object, processed one at a time through the operator chain (whole-stage codegen fuses the calls, Sec 05, but each row is still a full pass).
No SIMD, JVM object overhead per row, JIT warm-up cost before the JVM's hot path even kicks in.
Rows are held in columnar batches — one contiguous array per column — and operators apply to a whole batch using SIMD-vectorized C++ kernels.
Better CPU cache efficiency, no JVM JIT warm-up, no per-row serialization — Microsoft's published TPC-DS benchmark: roughly 4× faster on a 1 TB workload versus vanilla Spark.
Where it sits in the plan — and where it doesn't
NEE integrates after Catalyst's logical and physical optimization (Sec 04) — every existing optimization (predicate pushdown, column pruning, AQE, cost-based rewrites) still runs exactly as it would without NEE. What changes is only how a supported operator executes once the plan is already decided.
- Supported operators offload to the vectorized Velox path.
- Unsupported operators fall back to JVM/Tungsten execution (Sec 05) automatically, with a columnar↔row conversion at the boundary — a real cost, but scoped to just that operator.
VeloxColumnarToRow nodes) to confirm what actually ran natively.
Spark JVM vs Native Execution — Fallback Mechanics
NEE is not a switch you flip and forget. Gluten intercepts the physical plan, swaps supported nodes for Velox C++ kernels, and leaves everything else on the JVM — inserting a columnar↔row conversion at every boundary. That cost model has a consequence most teams discover the hard way: a plan that falls back mid-flight can be slower than running with NEE off entirely, because you pay conversion overhead without sustained native execution. Microsoft states the gain is greatest when queries avoid triggering fallback, and that the engine suits compute-intensive rather than I/O-bound work.
Fallback trigger taxonomy — four tiers
| Tier | Trigger | What to do |
|---|---|---|
| 1 — Whole query | Structured Streaming; ANSI mode on (Runtime 2.0 default); non-native sources (JSON, XML) |
Nothing native runs. For streaming, size compute on JVM performance. For ANSI,
make the deliberate choice in Sec 17 —
ansi_strategy in the advisor sets it per workload.
|
| 2 — Operator | An operator with no Velox equivalent (certain window frames, joins, aggregate variants) |
That node runs on JVM with conversions either side. Check the plan for where the
*Transformer chain breaks; restructure the query so the unsupported
operator sits at the edge, not the middle.
|
| 3 — Expression | A single unsupported function or Python UDF inside an otherwise-native operator | The sneaky one: one expression drops the whole enclosing operator to JVM. Replace with built-ins; this is why "UDFs are slow" understates the damage under NEE. |
| 4 — Type | Deeply nested structs/maps, complex-type manipulation | Flatten to columnar operations where the logic allows; nest once at the edge rather than repeatedly in the hot path. |
The authoritative operator and expression support list lives in the Apache Gluten documentation and changes release to release — this page deliberately teaches the method rather than freezing a support matrix that goes stale.
Three ways to detect fallback (use all three)
| Method | What you look for |
|---|---|
| 1. Read the plan |
df.explain() — native operators carry *Transformer and
*NativeFileScan suffixes; VeloxColumnarToRowExec marks
a conversion boundary. No suffixes at all with NEE enabled means everything fell
back.
|
| 2. Spark Advisor (inline) | Real-time fallback visibility in the notebook cell output — when a plan segment falls back, Advisor raises the alert as you run, with the unsupported operator/configuration named. The fastest feedback loop available. |
| 3. Diagnostics pane | Monitoring hub → "Native Execution Engine fallback detected" → View root cause / View fallback details (see Sec 21) — the post-run, whole-application view. |
| 4. Automated |
spark_plan_analyzer N-codes: N001 non-native format, N002
UDF-forced fallback, N003 ANSI×NEE on Runtime 2.0, N004 streaming, N005 nested
types, N006 high conversion-boundary ratio, N007
zero native operators despite NEE on.
|
Interactive: fallback simulator
Describe your query; get the predicted native/fallback outcome, the triggering tier, and the rewrite. Heuristic — always confirm against the plan and Advisor.
NEE on Runtime 2.0 vs 1.3 — Critical Differences
- NEE supports both Runtime 1.3 (Spark 3.5 / Delta 3.2) and Runtime 2.0 (Spark 4.1 / Delta 4.2) — it is not a 2.0-only feature.
- Runtime 2.0 broadens coverage: vectorized CSV parsing, Python and Scala UDF support, and complex types.
- Native Delta write acceleration extends native execution into the output path rather than read/transform only — relevant for write-heavy silver pipelines.
-
The catch on 2.0: ANSI defaults to on and NEE falls back under ANSI, so an
untouched Runtime 2.0 environment with NEE "enabled" may be running almost entirely
on the JVM. This is the single most important thing to verify after migrating
(N003/N007 catch it;
nb_nee_fallback_analyzermeasures it).
ANSI Mode × NEE Interaction Matrix
| Runtime | ANSI Mode | NEE Status | Result |
|---|---|---|---|
| Runtime 1.3 | OFF (default) | Enabled |
✅ Full native execution. Velox C++ kernels active.
*Transformer operators appear in plan.
|
| Runtime 1.3 | ON (explicit) | Enabled |
⚠️ Partial fallback. ANSI-affected operators fall to JVM. Check plan for
VeloxColumnarToRowExec boundaries.
|
| Runtime 2.0 | ON (default) | Enabled | 🔴 Silent full JVM fallback. ANSI is ON by default → NEE cannot run. N003/N007 fire. Common post-migration trap! |
| Runtime 2.0 | OFF (explicit) | Enabled |
✅ Full native execution restored. Set
spark.sql.ansi.enabled=false per-workload in Environment props.
V-Order write path now also native.
|
| Any | Any | Disabled | ⬜ JVM path only. Whole-Stage CodeGen applies but Velox never runs. Useful for streaming, or as a debugging baseline. |
spark_plan_analyzer or check for N003 in the Advisor pane. If you see
zero *Transformer operators in your plan with NEE enabled, ANSI is
almost certainly the cause. Set spark.sql.ansi.enabled=false in the
Environment's Spark properties (not in-notebook — that fires after the fallback
decision).
Rewrite Patterns — Removing Fallback Operators
| Fallback Trigger Found in Plan | Native Replacement | Explanation |
|---|---|---|
| BatchEvalPython (row UDF) |
F.lower(), F.trim(), F.regexp_replace() built-ins, or
@pandas_udf
|
Row UDFs cross JVM↔Python on every row and break codegen. Arrow-based pandas UDFs batch 1000+ rows per crossing. |
| EvalPython (UDF in filter) | Move filter to native predicate; precompute column | A UDF in WHERE prevents predicate pushdown AND kills NEE. |
| GenerateExec (EXPLODE on deep nested) | Flatten struct at bronze → silver step, then EXPLODE flat arrays | Deep nested type manipulation is a Tier-4 fallback trigger. Flatten once at the source. |
| WindowExec (complex frame) |
Use rangeBetween with numeric offsets not interval types where
possible
|
Certain window frame specifications (interval-based rows) have no Velox equivalent. Convert to row-number approaches where logic allows. |
Unified Memory Management & Off-Heap Arenas
Every executor is a JVM with a fixed heap (spark.executor.memory), carved
up by Spark's UnifiedMemoryManager (the default since Spark 1.6) into regions
that compete for the same pool. Drag the sliders — the bar below recomputes exactly
the way a real executor's memory manager would.
What each region is for
| Region | Purpose |
|---|---|
| Reserved (300 MB, fixed) | Hard-coded, not configurable. Holds Spark's own internal objects. Not part of any fraction calculation. |
| User memory |
(heap − 300MB) × (1 − memory.fraction). Your own data structures if
you're writing RDD code with UDFs that allocate, plus safeguards against unusually
large individual records.
|
| Execution memory | Shuffles, joins, sorts, aggregations — anything that needs working memory mid-computation. |
| Storage memory |
.cache()/.persist()'d data and broadcast variables.
|
spark.executor.memory, not carved out of it. Default
formula: max(384 MB, 0.10 × executor memory). Covers JVM internals,
PySpark worker processes, and native/off-heap allocations outside the JVM heap
entirely. Getting this wrong is the single most common cause of an executor OOM that
isn't a data problem — and it's a session-start-only setting (Sec 14): it can't be changed on a session that's already running.
Partitioning, Sizing & Parallelism
A partition is the unit of parallelism: one task processes exactly one partition. Too few partitions and cores sit idle; too many and per-task scheduling overhead dominates. Two separate settings control partition count depending on where in the plan you are.
| Setting | Default | Controls |
|---|---|---|
| spark.sql.files.maxPartitionBytes | 128 MB | Partition size when reading files (Parquet/Delta/CSV/JSON). Read-side only — AQE does not touch this one. |
| spark.sql.shuffle.partitions | 200 | Number of partitions produced by a shuffle (join, groupBy, repartition). With AQE's coalescing on, this is really just the initial/maximum count — AQE merges down from here. |
The small-files problem
When average source file size is well under the partition target, Spark doesn't
automatically launch one task per tiny file —
spark.sql.files.openCostInBytes (default 4 MB) estimates the fixed cost
of opening a file and packs several small files into one partition/task accordingly.
But this only compensates at read time; it doesn't fix the underlying problem of
driver-side file-listing overhead and Delta/Parquet metadata bloat from having
millions of tiny files in storage. A compaction pass (e.g. Delta
OPTIMIZE) upstream is the real fix.
Shuffle Internals, Spill & Sorters
A shuffle physically redistributes data across the cluster so that rows sharing a key end up on the same executor — required by every wide transformation (Sec 03). This is Spark's most expensive common operation, and the one most of the rest of this document (AQE, NEE, Efficient Scaledown) exists to make cheaper or safer.
The write path
Since Spark 2.0, all shuffle behaviour runs through SortShuffleManager, which selects one of three writers per task:
ExternalSorter: buffers, sorts by partition, spills to disk under
memory pressure.
Whichever writer runs, the physical output per task is a data file (compressed, serialized records grouped by destination partition) plus an index file (byte offsets marking where each partition's slice starts/ends) — so a reduce task can seek directly to its range instead of scanning the whole file.
The read path
MapOutputTracker (driver-resident, cached on executors) holds the authoritative
map from (shuffleId, partition) to the BlockManagerId that
holds it. BlockManager (one per executor) serves the actual bytes over the
network on request. A reduce task's BlockStoreShuffleReader looks up
locations, fetches blocks in parallel, and returns a sorted iterator over the result.
FetchFailedException) and the DAGScheduler recomputes the
entire owning stage — not just the missing partition. This is precisely the
coupling Efficient Scaledown's Remote Shuffle Manager and Shuffle Migration exist to
break (Sec 13), and it's the reason executors
can't simply be scaled down the moment they're idle.
Broadcast Hash Joins & Driver OOMs
The cheapest way to join two DataFrames is to avoid shuffling either of them. If one side is small enough to fit comfortably in memory, Spark can send a full copy to every executor (a broadcast) and perform the join locally, entirely avoiding a shuffle exchange for that operation.
| Strategy | When | Cost |
|---|---|---|
| Broadcast hash join |
One side's estimated size is below
spark.sql.autoBroadcastJoinThreshold (default 10 MB)
|
No shuffle. Driver collects the small side and broadcasts it once; every executor pays the memory cost of holding a full copy. |
| Sort-merge join | Neither side is small enough to broadcast | Both sides shuffled (partitioned by join key), then sorted and merged. The default, general-purpose strategy. |
| Shuffled hash join |
AQE converts to this when post-shuffle partitions are all small enough
(spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold)
|
Avoids the sort step of sort-merge when a partition-local hash map is cheap enough to build. |
spark.sql.autoBroadcastJoinThreshold to
100–500 MB once you know a given dimension table comfortably fits, since the runtime
saved by skipping a shuffle usually outweighs the extra memory every executor now
holds. The Config Advisor (Sec 15) computes a
specific recommendation from an actual known table size, with 1.5× headroom.
Dynamic Allocation & Remote Shuffle Manager (RSM)
Full internals (sequence diagrams, config reference, storage/networking constraints) live in the companion document "Efficient Scaledown & the Remote Shuffle Manager — Internals Reference." This section is the condensed version, connecting it back to everything above.
Why elastic scaling and shuffle fight each other
Dynamic allocation (spark.dynamicAllocation.enabled) lets Spark
release idle executors and request new ones as load changes. But an executor holding
shuffle output (Sec 11) isn't safely idle even
with zero running tasks — a not-yet-scheduled task may still need to fetch from it.
Left alone, this either blocks scale-down (executor sits idle, billed, protecting
data) or risks a costly stage recompute if the executor is removed anyway.
Spark's own fix: graceful decommissioning (3.1+)
Before an executor is torn down, if
spark.storage.decommission.shuffleBlocks.enabled
is on, Spark migrates its shuffle blocks to a peer executor first — or to configured
fallback storage if no peer has room. Only once every block has a new home does
the executor actually exit.
Fabric's four Efficient Scaledown capabilities
spark.sql.rsm.decisionlayer.enabled.level=stage must be set to prevent
tiny shuffles from incurring high Azure Blob HTTP REST latency.
Configuration Reference & Scopes
Every Spark setting mentioned in this document, in one searchable table — default value, which section explains it, and whether it's safe to change on a running session or needs to be set before one starts (Sec 14 → this exact distinction is what broke the notebook utility during testing; see the companion notebook for the full story).
spark.sql.ansi.enabled false → true;
spark.sql.maxSinglePartitionBytes unlimited → 128m;
spark.sql.orc.compression.codec snappy → zstd;
spark.speculation.multiplier 1.5 → 3 and
quantile 0.75 → 0.9;
spark.shuffle.service.removeShuffle off → on; shuffle-service DB →
RocksDB. Unbadged DEFAULT values are the Spark 3.5 baseline shared by both
runtimes.
| Setting | Default | Scope | What it controls |
|---|
Interactive Config Advisor (Live Heuristics & Medallion Presets)
Heuristic calculation engine tailored for Microsoft Fabric Spark pools. Generates minimal delta configurations (omitting platform defaults to avoid pinning debt) partitioned by Medallion layer and architectural scope.
• Table Properties (DDL): Deletion vectors, Change Data Feed, Liquid clustering, V-Order defaults (permanent storage metadata).
• Notebook Session (
%%configure -f): Temporary job-specific
overrides (e.g. tuning advisoryPartitionSizeInBytes for a massive 200GB
batch).
Fabric Compute Reference & Node Sizing
The concrete numbers behind every Fabric-specific recommendation above.
Node sizes (memory-optimized — fixed 8 GB RAM per vCore)
| Node size | vCores | Memory |
|---|---|---|
| Small | 4 | 32 GB |
| Medium | 8 | 64 GB |
| Large | 16 | 128 GB |
| XLarge | 32 | 256 GB |
| XXLarge | 64 | 512 GB |
XLarge and XXLarge require a non-trial Fabric SKU. Source: Microsoft Learn, "Apache Spark compute for Data Engineering and Data Science."
Capacity units → Spark vCores
1 Capacity Unit (CU) = 2 Spark vCores. A pool's maximum size is bounded by the capacity SKU hosting it — not every node size is reachable on every SKU.
| SKU | Capacity units | Spark vCores | Starter pool default max nodes |
|---|---|---|---|
| F2 | 2 | 4 | 1 |
| F4 | 4 | 8 | 1 |
| F8 | 8 | 16 | 2 |
| F16 | 16 | 32 | 3 |
| F32 | 32 | 64 | 8 |
| F64 | 64 | 128 | 10 |
| F128 | 128 | 256 | 10 |
| F256 | 256 | 512 | 10 |
Starter pools default to Medium node size. Source: Microsoft Learn, "Configure starter pools."
Starter pool vs. custom pool
- Starter pool — pre-hydrated, ~5 second session start, fixed at Medium node size. You can still adjust autoscale bounds and dynamic executor allocation, but not the node size itself.
- Custom pool — you choose node size and full autoscale/dynamic-allocation configuration, at the cost of a normal (slower) session cold start.
Within a node: cores and memory are still adjustable
Choosing a node size fixes the executor's ceiling, not necessarily what you
use. A Fabric environment's Compute pane lets you under-populate an executor's core
count (e.g. run an 8-vCore node's executor at 4 cores, trading parallelism for
per-task memory headroom) and choose among a fixed menu of executor-memory values for
that node size — both are session-start settings (Sec 14), configured via the environment or a %%configure cell, not
spark.conf.set() mid-session.
Spark 3.5 vs 4.x — Runtime 1.3 vs Runtime 2.0
Fabric currently offers two runtime generations. Runtime 1.3 (Apache Spark 3.5, Delta Lake 3.2) is GA and the recommended production runtime. Runtime 2.0 launched at FabCon Atlanta (March 2026) in Public Preview on Spark 4.0/Delta 4.0 and has since been upgraded in place to Spark 4.1 / Delta Lake 4.2 — as of mid-2026 it is GA: production-ready. Note it is not yet the default runtime, so you must select it explicitly at workspace or Environment level.
| Component | Runtime 1.3 (GA) | Runtime 2.0 (GA) |
|---|---|---|
| Apache Spark | 3.5 | 4.1 (launched as 4.0, upgraded in place) |
| Delta Lake | 3.2 | 4.1 |
| Java / JDK | 11 | 21 |
| Scala | 2.12 | 2.13 |
| Python | 3.11 | 3.13 |
| OS | Mariner 2.0 | Azure Linux 3.0 |
Interactive: what actually changed in Spark 4.x
| Change | Spark 3.5 | Spark 4.x | Impact |
|---|
spark.sql.ansi.enabled=true). Operations that silently returned NULL on
Spark 3.5 — arithmetic overflow, divide-by-zero, invalid casts, out-of-range array
indexing — now throw runtime exceptions. Audit any logic that depends on silent NULLs;
use try_cast, try_divide, try_add etc., or set
spark.sql.ansi.enabled=false
during migration to isolate the runtime upgrade from data-quality cleanup.
spark.sql.ansi.enabled=false to get native vectorized
speed. Decide per workload class — data-quality-critical silver pipelines may prefer
ANSI; scan-heavy gold aggregations may prefer NEE — and revisit as Microsoft extends
native ANSI coverage.
Migration checklist: Runtime 1.3 → 2.0
- Re-publish every Environment that has libraries. The Python 3.11 → 3.13 jump means custom and public libraries must be re-added and Published, or jobs fail with "No module found" / "Class not found". Export the library list first.
-
ANSI audit (above) — pre-test on Runtime 1.3 by setting
spark.sql.ansi.enabled=truein a dev environment and running your suite before you ever switch runtimes. - Scala/JVM code: Scala 2.12 → 2.13 and JDK 11 → 21 mean any custom JARs must be recompiled.
- Deprecated paths: WASB protocol → use ABFS; the old EventHubConnector → use the Kafka Spark connector; SparkR is deprecated in Spark 4.x.
-
Config default drift from upstream Spark 4.x: ORC compression snappy → zstd,
spark.sql.maxSinglePartitionBytesnow 128m (was unlimited), speculation is less aggressive (multiplier 1.5 → 3, quantile 0.75 → 0.9), external shuffle service now removes shuffle files for deallocated executors by default. - Delta 4.x-only table features (e.g. collations, VARIANT columns) are currently Spark-only — enabling them on a table breaks readability from other Fabric engines (SQL endpoint, older readers). Gate them behind interop review; deletion vectors and CDF are fine on both runtimes.
Feature Explorer — Engine Compatibility
Driven by the runtime selector in the top bar (currently: Runtime 1.3). Features are grouped by which engine ships them — SPARK ENGINE DELTA ENGINE FABRIC PLATFORM — because that is what actually gates availability: the Spark version and Delta version come from the runtime you pick (Sec 17); Fabric platform features mostly work on both. Cards that need Runtime 2.0 dim out when 1.3 is selected; switch the selector to see what upgrading unlocks.
Capacity Units, Smoothing, Bursting & Throttling
Every Fabric Spark job draws from a capacity (F-SKU). Understanding the CU model is what turns "the job randomly failed with HTTP 430" into a predictable, monitorable system.
The CU model in three equations
| Rule | Meaning |
|---|---|
| 1 CU = 2 Spark vCores | An F64 capacity = 64 CU = 128 Spark vCores of base compute. |
| Burst factor = 3× (concurrency) | Fabric Spark lets concurrent jobs collectively use up to 3× base vCores (F64 → up to 384 vCores across jobs). Bursting aids concurrency, not one job's max size — a single job is still bounded by its pool's max nodes. |
| CU-seconds = vCore-seconds ÷ 2 | Billing/consumption: a Medium node (8 vCores) running 10 minutes consumes 8 × 600 ÷ 2 = 2,400 CU-seconds. |
Smoothing — why a Spark spike doesn't instantly throttle you
Fabric classifies Spark operations as background operations, and background usage is smoothed over 24 hours: a burst of consumption is spread across the next day's capacity rather than billed against the minute it ran. This is why a heavy overnight batch doesn't immediately break morning interactive work — but it also means overuse accumulates silently as carryforward overage that must be burned down. Watch cumulative utilisation, not instantaneous.
| Cumulative overage | Effect |
|---|---|
| ≤ 10 minutes of future capacity | Nothing — overage protection; jobs run, debt carried forward. |
| 10 – 60 minutes | Interactive delay — user-submitted operations wait ~20 seconds at submission. |
| 60 minutes – 24 hours | Interactive rejection — user-submitted operations refused; scheduled/background continue. |
| > 24 hours | Background rejection — everything refused until the overage burns down. |
Job admission: what HTTP 430 actually means
Fabric Spark uses optimistic job admission: a job is admitted if its
minimum node count fits, then grows toward its max via autoscale if capacity
allows. When even the minimum doesn't fit: interactive (notebook-submitted)
jobs are rejected immediately with HTTP 430 — TooManyRequestsForCapacity,
while scheduled/pipeline-triggered jobs enter a SKU-dependent queue and
start when capacity frees. Practical consequences: set realistic pool minimums (a min
of 1 node admits far more often than a min of 10), and put retry-with-backoff on any
automation that submits interactively.
Emitting issues early — before users feel them
- Capacity Metrics App — the canonical view of utilisation, smoothing, carryforward overage and per-item CU attribution. Review the overage tab weekly, not just after incidents.
- Surge protection — set a background-rejection threshold on the capacity so runaway background jobs get refused before they push the capacity into the interactive-rejection band that hurts users.
- Activator / alerting on capacity metrics — alert at e.g. 80% smoothed utilisation and on any throttling event, rather than discovering 430s from users.
-
Fabric Apache Spark diagnostic emitter — ship Spark driver/executor logs and
metrics to Log Analytics, Azure Storage, or Event Hubs; alert on
FetchFailedException, OOM patterns, and admission failures centrally (Sec 21). - Autoscale Billing for Spark — moves Spark to serverless pay-as-you-go compute billed per job, off the capacity entirely: the right lever when Spark contends with Power BI/warehouse workloads on a shared capacity.
Where settings actually live — the configuration hierarchy
Every knob in this document belongs to exactly one of five layers. Knowing which layer owns a setting tells you where to change it and who it affects — and mirrors what you see in the Fabric portal (Workspace settings → Data Engineering/Science → Spark settings, with its Pool / Environment / Jobs / High concurrency tabs).
| Layer | What lives here |
|---|---|
| 1 — Capacity (admin portal) | SKU (CU), Job bursting switch, surge protection, Autoscale Billing for Spark. Affects every workspace on the capacity. |
| 2 — Workspace Spark settings | Pool tab: default pool (Starter = Medium nodes, autoscale 1–10, ~5–10s start) or custom pools — node family/size, autoscale range, dynamic executor allocation. Environment tab: workspace default Environment and default runtime version — this is where Runtime 1.3 vs 2.0 is selected. Jobs tab: job admission behaviour (e.g. reserving max cores for active jobs — trades throughput for guaranteed completion). High concurrency tab: session sharing for notebooks/pipelines. Plus "Customize compute configurations for items" — if off, items cannot override the workspace pool. |
| 3 — Environment item | Runtime version override, attached pool + compute overrides (driver/executor size, dynamic allocation), Spark properties (the right home for team-wide session-start settings from Sec 14), libraries (public + custom — remember the re-publish rule when moving to Runtime 2.0, Sec 17), and the Acceleration tab — the NEE toggle. |
| 4 — Notebook / job session |
%%configure -f at the top of a notebook (session-start scope:
executor shape, memory fractions, Efficient Scaledown keys) and per-SJD submit
parameters. Requires "Customize compute" on at layer 2.
|
| 5 — Running session |
spark.conf.set() for runtime-mutable keys only (AQE, shuffle
partitions, broadcast threshold, ANSI…). The RUNTIME/SESSION-START split in
Sec 14 is exactly the layer-4 vs layer-5
boundary.
|
Precedence flows downward: a notebook's %%configure beats the
Environment's Spark properties, which beat workspace defaults. Debug rule: when a
setting "doesn't stick", check whether a lower layer is overriding it — or whether
it's session-start scope being set too late.
Interactive: capacity planner
CU Accounting & Per-Entity Chargeback
The Capacity Metrics App tells you which item consumed capacity. It does not tell you which entity in a metadata-driven framework did — one generic notebook running 40 entities appears as one notebook. Closing that gap is a logging decision, not a platform feature.
Extending etl_run_log
| Column | Purpose |
|---|---|
| engine |
python-notebook | spark | copy-activity —
makes the engine-choice decision auditable after the fact.
|
| vcores | What the run actually had (2 for a default Python notebook; nodes × node vCores for Spark). |
| duration_seconds | Wall-clock of the work itself. |
| cu_seconds |
Estimate: duration_seconds × vcores ÷ 2 (1 CU = 2 Spark
vCores).
|
| rows_read / bytes_landed / files_landed | Denominators for cost-per-row and cost-per-GB trends — the numbers that make a regression visible. |
| requests_made / throttle_waits | API-specific: distinguishes "the source got slower" from "our code got slower". |
| spark_app_id | The jump-off into the Monitoring hub (Sec 21). |
Where to write it
| Target | Use when |
|---|---|
| Fabric SQL Database |
Default. Batched executemany at the end of each run (Sec 36). Transactional, joinable to the rest of the metadata, queryable from
pipelines. Never one insert per row.
|
| Eventhouse (KQL) | Streaming-scale or very high run counts, where append throughput and time-series queries matter more than joins. Sits naturally beside workspace-monitoring data (Sec 21). |
| Delta table in a Lakehouse | Acceptable fallback when no SQL DB exists — but expect small-file churn from frequent appends, and schedule maintenance accordingly (Sec 33). |
The supported inversion: your own run log is the cross-workspace rollup, because every run writes to one central SQL Database regardless of which workspace executed it. Reconcile against the app periodically by eye, and expect drift — your estimate omits session startup, library installation and idle session time, and the app itself excludes library-management consumption and system Spark jobs, while attributing all Spark work as background against the notebook, SJD or lakehouse item. Data there is also subject to a 10–15 minute refresh latency, which is precisely why an in-run estimate is useful: it is available immediately.
Troubleshooting UI, Skew & History Server
Everything Sec 01–Sec 13 explained becomes practical here: each internal mechanism leaves a specific fingerprint in the Spark UI, and reading those fingerprints is how you diagnose slow or failing jobs instead of guessing. In Fabric: Monitoring hub → your application → Spark UI / Spark history server, or from a notebook's run status bar → "View run details". Logs live under the same detail view (driver stdout/stderr, executor logs), and the in-notebook Spark Advisor surfaces common issues inline.
| Symptom | Where in the UI | What it means |
|---|---|---|
| Disk / memory spill | Stages → stage detail → summary metrics: "Spill (memory)" / "Spill (disk)" columns non-zero | Execution memory (Sec 09) couldn't hold the working set — sort/aggregate/join buffers overflowed to disk. Every spilled byte is written and re-read. |
| Skew | Stage summary: max task duration ≫ 75th percentile; one task processing GBs while median processes MBs | Hot keys concentrated in one partition (Sec 06's skew-join handles joins; aggregation skew needs salting). |
| Executor OOM |
Executors tab: dead executors; logs show exit code 137 /
java.lang.OutOfMemoryError
|
Task working set exceeded heap+overhead (Sec 09). Distinct from driver OOM. |
| Driver OOM | Job dies at collect/broadcast step; driver log OOM |
Usually collect()/toPandas() on big data, or
broadcasting a "small" table that wasn't (Sec 12).
|
| Stage retry storm |
Jobs tab: stages with attempt > 0; FetchFailedException in
failed-task reason
|
Shuffle blocks lost with their executor (Sec 11, Sec 13) — the exact problem Efficient Scaledown addresses. |
| Too many tiny tasks | Stage with thousands of sub-second tasks | Over-partitioning or the small-files problem (Sec 10); scheduling overhead dominates. |
| AQE plan changes |
SQL/DataFrame tab: AdaptiveSparkPlan isFinalPlan=true,
AQEShuffleRead coalesced nodes; driver log "Plan changed from…"
|
Normal — AQE re-optimizing (Sec 06). Compare initial vs final plan to understand what runtime stats changed. |
| NEE fallbacks |
SQL plan: operators without *Transformer/NativeFileScan
suffixes; VeloxColumnarToRowExec conversions; Gluten tab
|
Part of the plan fell back to JVM (Sec 07) — check for streaming/JSON/XML/ANSI triggers before assuming NEE is "on". |
| HTTP 430 at submit |
Job never starts; submission error TooManyRequestsForCapacity
|
Capacity admission failure (Sec 19), not a Spark problem — check Capacity Metrics App. |
Interactive: symptom → diagnosis → fix
The monitoring detail view, tab by tab — and where Livy fits
Before Spark ever runs your code, Fabric routes the submission through
Apache Livy, the REST session layer: notebook or Spark Job Definition → Livy
session request → capacity admission (Sec 19) →
container acquisition → Environment resolution (runtime version, libraries, Spark
properties) → spark-submit → session idle, ready for statements.
Each notebook session is a Livy session with an id and state machine (not_started → starting → idle ⇄ busy → dead). This matters diagnostically because a whole class of failures happens
at the Livy layer, before any Spark job exists — capacity 430s, library
resolution conflicts, un-published Environments, session timeouts — and those appear
in the Livy log, not in Spark's UI.
| Tab | What it shows / when to use it |
|---|---|
| Jobs |
Job → stage → task tree with live progress, durations, rows and data
read/written per job. Failed stages surface here first (e.g. a failed
genShuffleDependency at VeloxSparkPlanExecApi.scala job — the Velox
class name in the description is itself a signal the plan ran on NEE). Click
through to the failing stage, then use the symptom table above.
|
| Resources | Executor allocation over time — cores in use vs allocated, when autoscale added/removed executors. The place to verify dynamic allocation and Efficient Scaledown behaviour (Sec 13) and to spot idle over-provisioning. |
| Logs |
Selector for Livy log (session bootstrap: admission, library install
progress, spark-submit line — read this when the session never started or took
minutes to start), Driver stdout/stderr (your
print()/logging output, collect-side errors, full stack traces),
and per-executor stderr by attempt (task-side OOMs, FetchFailed detail).
Filter by errors/warnings, download for offline grep.
|
| Data | Input/output summary per job — quick sanity check that a job actually read/wrote the volumes you expected (a 0-row read often means a wrong path/filter, not a performance problem). |
| Item snapshots | A read-only snapshot of the notebook/SJD as it was when this run executed, including parameter values. Indispensable when the notebook has been edited since the failing run — you debug the code that ran, not the code that exists now. |
| Diagnostics pane | Fabric's built-in advisor over the run: Data and time skew (skewed task %, max vs avg executor time, max/min/avg data read, skewness score — its thresholds catch what the percentile check above finds manually) and Native Execution Engine fallback detected, with "View root cause" naming the operator/expression that forced the JVM path (Sec 07) and "View fallback details" listing every fallback. Check this pane before manual archaeology — it often answers the question outright. |
| Monitor run series | Duration and data-volume trend across runs of the same activity. The regression detector: a job that crept from 4 to 11 minutes over two weeks shows as a slope here long before anyone complains — pair with Autotune to see whether tuning is converging. |
| Spark History Server | The full classic Spark UI (SQL tab, stage detail, event timeline) for completed runs — everything in the symptom table above, post-mortem. Event-log based, so it works after executors are long gone. |
java.lang.OutOfMemoryError or exit 137 just before the gap).
"Where did the time go" → History Server SQL tab + stage metrics. "Is this run worse
than usual" → Monitor run series. "Is NEE actually accelerating" → Diagnostics
fallback panel + *Transformer suffixes in the SQL plan. "Is the platform
throttling me" → Capacity Metrics App, not this view at all (Sec 19).
Centralised log emission — catch it before the user does
The Fabric Apache Spark diagnostic emitter (configured per Environment) streams
driver/executor logs and metrics to
Azure Log Analytics, Azure Storage, or Event Hubs. Once logs land in Log
Analytics, standing KQL alerts on OutOfMemoryError,
FetchFailedException, spill metrics, and 430 admission failures turn
every failure class on this page into a proactive signal instead of a user report.
Pair with the emitted Spark event log (see "Observable artifacts",
Sec 03) for full-fidelity postmortems.
Spark Logs REST API & Automated Diagnostics
The Spark UI is not actually confusing — it is five separate surfaces that look like one, and nobody tells you which one answers which question. This section maps them, then shows the part most people never discover: every one of them is a documented REST API, so "this cell is slow" can become a script instead of a click-through.
The five surfaces, and what each one actually is
| Surface | Where | What it is |
|---|---|---|
| In-cell progress | Below the running cell | A live job/stage/task progress bar scoped to that cell — the only surface that already answers "which cell" without you asking. |
| Spark Advisor (in-cell) | Expandable panel under the cell | Real-time static + runtime analysis — Info/Warning/Error counts, skew detection — generated as the cell runs. This is the notebook lightbulb, and it has a REST API twin (below). |
| Recent runs / Monitoring hub | Item context menu → Recent runs, or Monitor in the nav | The list of Spark applications (one per notebook run), each with a Livy Id and an Application Id — your entry point into everything else. |
| Application detail | Click an application from Recent runs | The tabbed view from Sec 21: Jobs, Resources, Logs, Data, Item snapshots, Diagnostics. |
| Spark History Server | Link from Application detail | The full classic OSS Spark UI — SQL tab, stage detail, event timeline — for completed runs. |
The 6 Application Detail Tabs & Key Production Capabilities
| Tab / Panel | Key Capabilities | Production Troubleshooting Value |
|---|---|---|
| Jobs Tab |
• High-Concurrency Filtering: In shared interactive sessions, filter jobs
by notebook to isolate your execution. • Job status, stage/task metrics, data read/written. • Code snippet popup to copy the exact cell SQL/PySpark code. |
Quickly isolates which job failed or hung in a multi-tenant session without wading through teammates' cells. |
| Resources Tab |
• Allocated vs. Active Executors: Visual timeline of elastic autoscale in
action. • Core & Memory time-series utilization charts. • Filterable by jobGroup (notebook cell).
|
Detects executor starvation, zombie idle nodes waiting on shuffle data (Sec 13), and capacity throttling boundaries. |
| Logs Tab |
• 3 Separate Log Streams: 1. Livy: Session submission, lifecycle & queuing.2. Prelaunch: Node warmup, container bootstrap & pool
allocation.3. Driver: Stdout, stderr, print outputs, log4j stack traces.• Search keyword filtering & one-click log download. |
When a job fails before running tasks, the Prelaunch/Livy
logs reveal pool allocation failures or wheel dependency conflicts that never
reach the Driver log.
|
| Data Tab |
• Automatic inventory of all input & output files. • Displays format (Delta/Parquet/CSV), size, source, and resolved OneLake paths. • Download files or copy ABFS paths directly. |
Identifies write amplification and verifies whether file skipping (Sec 24b) actually pruned partitions without writing diagnostic query plans. |
| Item Snapshots |
• Hierarchical tree of parent Pipelines, Notebooks, and SJDs. • Immutable code snapshot: Captures the exact notebook cell code, widget inputs, and parameters at execution time. |
Guarantees reproducible root-cause analysis even if developers modify or overwrite the notebook after the scheduled run finishes. |
| Diagnostics & Advisor |
• Spark Advisor Rule Engine: Info, Warning, Error alerts. • Automated checks: Data skew (>3× task gap), Cartesian products (cross joins), uncoalesced tiny partitions, broadcast hash join candidates, GC pressure. |
Automates 80% of routine performance triage with structured remediation hints directly in the browser. |
The REST API layer — the same data, scriptable
Fabric exposes Spark application internals as a REST API that mirrors the OSS Spark History Server API contract exactly — same endpoints, same parameters, same JSON shapes for jobs, stages, tasks, executors, storage and streaming. On top of that, Fabric adds endpoints OSS Spark does not have: the Advisor and resource-usage graphs, as APIs.
Endpoint (under /applications/{appId}/{attemptId}/…)
|
Answers |
|---|---|
| jobs |
Every Spark job in the application — duration, status, stage count. Filter/sort
by duration to find the slow one; each job carries its
jobGroupId back to the cell.
|
| stages / stages/{id}/{attempt}/taskList | Per-stage summary and per-task detail — the shuffle read/write, spill, and max-vs-median duration numbers that diagnose skew (Sec 21's symptom table, as JSON). |
| executors / allexecutors | Executor lifetime, GC time, task counts — dead executors and their timing point straight at Sec 09-style OOM diagnosis. |
| environment | The full effective Spark config for the run — confirms what actually applied vs. what you intended (Sec 14). |
| advice |
Fabric-only. The Spark Advisor's Info/Warning/Error findings, as
structured data — filterable by stageId, jobId,
jobGroupId, executorId. Programmatic access to the
same skew-detection and anti-pattern analysis the notebook UI shows.
|
| resourceUsage |
Fabric-only. Executor CPU/memory utilization over time, filterable by
jobGroup — the Resources-tab chart as numbers you can threshold and
alert on.
|
| logs?type=driver&fileName=… |
Fabric-only. Raw driver stdout/stderr text — your
print() output and full stack traces, fetchable without opening the
portal.
|
Base pattern:
GET
/v1/workspaces/{workspaceId}/notebooks|sparkJobDefinitions|lakehouses/{itemId}/livySessions/{livyId}/applications/{appId}/{attemptId}/{endpoint}. Auth: a pre-authenticated Fabric REST client from inside a notebook, or an Entra
app/SPN token from outside (the same token pattern as Sec 27's SQL Database connectivity).
Turning "this cell is slow" into a diagnosis — the workflow
- Get the application id + Livy id of the run that felt slow (Recent runs, or captured at session start).
-
List jobs, sort by duration, and note the
jobGroupIdof the slowest one — that is your cell. - List that job's stages, sort by duration; pull the slowest stage's task list.
- Compute max-vs-median task duration from the task list — a large gap is skew, not general slowness (Sec 21).
- Check spill in the stage summary metrics — non-zero spill means the fix is partition sizing or executor shape (Sec 09), not more time.
- Pull Advisor findings for that job/stage — often this step alone names the cause.
-
Cross-check resource usage for the same
jobGroupwindow — confirms whether executors were memory-starved or just busy.
This is exactly what nb_fabric_log_diagnostics.ipynb automates — point it
at an application id and it runs steps 2–7 and prints a ranked diagnosis instead of
five tabs of manual cross-referencing.
Interactive: which surface answers your question
nb_fabric_log_diagnostics.ipynb implements the
client for every endpoint above and exercises the full workflow against realistic,
schema-accurate simulated responses (clearly labelled demo mode) — the live-Fabric
calls are unchanged code, only the transport differs. Executed locally end to end,
zero errors.
Log Analytics, KQL & Event Log Analysis
Two distinct things people conflate. The diagnostic emitter streams logs and metrics continuously to an external destination — good for alerting and fleet monitoring. Event logs are the per-application raw event stream the Spark UI is rendered from — good for deep post-mortem analysis of one run. You want both, for different reasons.
Minimum emitter configuration — Log Analytics
Set these as Spark properties on an Environment (not in a notebook — this is session-start scope). Five properties is the whole thing:
spark.synapse.diagnostic.emitters: MyLA
spark.synapse.diagnostic.emitter.MyLA.type: "AzureLogAnalytics"
spark.synapse.diagnostic.emitter.MyLA.categories: "Log,EventLog,Metrics"
spark.synapse.diagnostic.emitter.MyLA.workspaceId: <LOG_ANALYTICS_WORKSPACE_ID>
spark.synapse.diagnostic.emitter.MyLA.secret: <LOG_ANALYTICS_WORKSPACE_KEY>
# Use Key Vault instead of an inline secret (do this in production):
spark.synapse.diagnostic.emitter.MyLA.secret.keyVault: <AZURE_KEY_VAULT_URI>
spark.synapse.diagnostic.emitter.MyLA.secret.keyVault.secretName: <SECRET_NAME>
# REQUIRED when using the default/starter pool - without it the emitter never engages:
spark.fabric.pools.skipStarterPools: "true"
-
spark.fabric.pools.skipStarterPools: "true"is required on the default pool. Starter pools are pre-warmed and do not pick up emitter configuration. Miss this line and everything looks configured but nothing arrives — the single most common failure. -
Category names differ by destination. Log Analytics uses
Log,EventLog,Metrics. Azure Storage and Event Hubs useDriverLog,ExecutorLog,EventLog,Metrics. Copying a Storage example into a Log Analytics emitter silently collects nothing useful. - The secret is the Log Analytics primary key (Azure portal → your LA workspace → Agents → Primary key), not an Entra token or a connection string.
- Users submitting Spark jobs need read access to the Key Vault secret when using the Key Vault route, or the emitter fails to initialise per-session.
| Destination | Use when |
|---|---|
| Azure Log Analytics | You want KQL, alert rules and workbooks over Spark logs, and you already have an LA workspace. Best for alerting. |
| Azure Storage | Cheap long-term retention of raw JSON lines. Shortcut the container into a Lakehouse and query it with Spark — the pattern for fleet-wide historical analysis. |
| Azure Event Hubs | Streaming onward into an Eventstream → Eventhouse, so Spark logs sit beside workspace-monitoring data in one KQL estate (Sec 21). |
Filtering exists too:
spark.synapse.diagnostic.emitter.<name>.filter.eventName.match
takes a comma-separated list of Spark event names, which is how you keep volume (and
cost) down when you only care about a few event types.
Event logs — the deeper source
The emitter's EventLog category and the per-application event log are the
same underlying event stream. The difference is access: the emitter pushes it
continuously to a destination you configured in advance; the REST API lets you
pull the complete log for a specific past application, whether or not you had
an emitter configured. For "this run was slow, why?", pulling the log is the direct
route.
GET https://api.fabric.microsoft.com/v1/workspaces/{workspaceId}/notebooks|sparkJobDefinitions|lakehouses/{itemId}/livySessions/{livyId}/applications/{appId}/{attemptId}/logs
# Returns binary application/zip stream containing JSON-lines Spark event logs
Runnable PySpark client: Paste any portal Monitoring-hub URL or REST endpoint,
acquire the token ambiently via notebookutils, and parse the raw event
log stream directly in memory:
import io, json, zipfile, requests, notebookutils
from spark_eventlog_analyzer import resolve_spark_rest_url
# 1. Accepts ANY portal monitoring URL or REST endpoint
portal_or_api_url = "https://app.fabric.microsoft.com/groups/0000.../sparkapplications/application_1741176604085_0001?workspaceId=...&livyId=...&itemId=..."
rest_url = resolve_spark_rest_url(portal_or_api_url, endpoint="logs")
# 2. Acquire ambient Entra ID Bearer token in Fabric Notebook
token = notebookutils.credentials.getToken("pbi")
resp = requests.get(rest_url, headers={"Authorization": f"Bearer {token}"})
# 3. Stream & Unzip in-memory (zero temp files written to disk)
events = []
if resp.status_code == 200:
with zipfile.ZipFile(io.BytesIO(resp.content)) as z:
for fname in z.namelist():
if not fname.endswith("/"):
with z.open(fname) as zf:
events.extend([json.loads(line) for line in zf if line.strip()])
print(f"Successfully parsed {len(events)} events from Fabric REST API.")
-
Missing
/{attemptId}/. The logs endpoint requires it — normally1, or2after a retry. Omitting it returns an unhelpful 400/404 that does not mention the attempt. -
Wrong item-kind segment. A Spark Job Definition's application under
/notebooks/404s. The segment must match the item that launched the app:notebooks,sparkJobDefinitionsorlakehouses. - Pasting the portal URL. The Monitoring-hub address contains the right ids but in a different shape — it is not an API endpoint. The ids are extractable, though.
-
Copy artefacts: quotes, a trailing slash, a
#fragment, or a query string picked up with the copy. - The application has not finished. Event logs finalise on completion; querying a running session returns nothing useful.
spark_eventlog_analyzer.parse_fabric_url() handles
(1)–(4) automatically — it accepts the REST URL, a relative path, or a portal URL
with ids in the query string, defaults the attempt to 1, and detects the item kind.
For anything it genuinely cannot parse it explains which part is missing rather than
failing opaquely.
What the event log gives you that the UI does not
| Analysis | Why the raw events are needed |
|---|---|
| Exact skew ratio per stage |
The UI shows a percentile summary; the event log has every
SparkListenerTaskEnd, so you can compute max/median precisely and
identify the executor that ran the straggler.
|
| Spill attribution |
Memory Bytes Spilled and Disk Bytes Spilled per task —
aggregate by stage, by executor, or correlate with peak execution memory.
|
| Critical path | Rank stages by wall time as a percentage of total stage time — tells you where a job actually spent its life rather than which stage looks busiest. |
| Effective configuration |
SparkListenerEnvironmentUpdate carries the actual config the
application ran with — ground truth versus what you believe you set (Sec 14).
|
| Executor lifecycle | Added/removed timestamps and removal reasons — how much of the run had how much compute, and whether executors were lost non-idle. |
| Cross-run trending | The logs are files. Parse a month of them into a Delta table and trend skew, spill and duration per pipeline — impossible through the UI. |
spark_eventlog_analyzer.py +
nb_eventlog_analysis.ipynb. The notebook generates a
real Spark event log in-session (including a deliberately skewed join), parses
it, and reports the critical path, skew ratios, spill, GC pressure and findings — with
assertions proving the configuration was read from the log rather than assumed,
and that the skew detector fired on real data. Every URL shape above is unit-tested.
The only unexercised part is the Fabric HTTP download itself, which needs a live
session.
Delta Table Optimization — V-Order, Liquid, DV, CDF
Storage-layout decisions have as much performance impact as any Spark setting. This section maps every Delta/Fabric optimization to when to turn it on, per medallion layer and per runtime.
| Feature | R1.3 / R2.0 | What it does & when to enable |
|---|---|---|
| Optimize Write | ✓ / ✓ (on by default) | Bin-packs writes toward a target file size (default bin 1 GB) at write time — the first defence against small files. Leave on everywhere; consider a smaller bin (e.g. 128 MB) for very merge-heavy tables so rewrites touch less data. |
| V-Order | ✓ / ✓ |
Write-time sorting/encoding optimized for Power BI Direct Lake and
SQL-endpoint reads, at ~10–15% write cost. Enable on
gold/consumption tables; disable on write-heavy bronze/staging. Check
your workspace's current default with
spark.conf.get("spark.sql.parquet.vorder.default") rather than
assuming — defaults have changed across Fabric updates.
|
| Deletion Vectors | ✓ / ✓ |
MERGE/UPDATE/DELETE mark rows in a sidecar bitmap instead of rewriting whole
files — dramatically faster mutations on large tables. Enable
(delta.enableDeletionVectors=true) on merge/update-heavy silver
tables; run periodic OPTIMIZE/REORG TABLE … APPLY (PURGE)
to physically remove soft-deleted rows; confirm all external readers support DV
first.
|
| Liquid Clustering | preview⚠ / ✓ |
CLUSTER BY (col…) replaces both partitioning and Z-ORDER for most
tables: incremental, no cardinality trap, re-clusterable without rewrite. Choose
1–4 columns you actually filter on. Mutually exclusive with partitioning. On
Runtime 1.3 (Delta 3.2) it's preview-gated; treat as standard from Runtime 2.0
(Delta 4.2) — with the Spark-only interop caution from
Sec 17 in mind.
|
| Partitioning | ✓ / ✓ | Legacy layout tool. Under ~1 TB it usually hurts (small files, listing overhead). Still legitimate for low-cardinality retention boundaries (e.g. partition by load date to drop old data cheaply). Prefer liquid clustering for query pruning. |
| Change Data Feed | ✓ / ✓ |
delta.enableChangeDataFeed=true records row-level changes;
downstream silver→gold jobs read only table_changes() since the
last processed version instead of re-scanning. The backbone of incremental
medallion processing.
|
| Materialized Lake Views | Fabric feature (preview) |
Declarative CREATE MATERIALIZED LAKE VIEW … AS SELECT — Fabric
manages refresh and lineage for silver→gold transformations. Prefer over
hand-rolled "rebuild the aggregate" notebooks where the transform is expressible
as SQL.
|
| OPTIMIZE + VACUUM | ✓ / ✓ |
Scheduled compaction (OPTIMIZE) fixes small files retroactively;
VACUUM reclaims storage past the retention window (default 7 days —
never lower it below your time-travel/CDF consumer needs).
|
How CDF works internally — and when it drives increments automatically
Without CDF the transaction log already lets you diff two versions, but only at file granularity: you can see which files were added and removed. For appends that is enough to derive inserted rows. For an UPDATE or MERGE it is not — a rewritten file contains changed and untouched rows, and nothing in the log tells you which is which.
Enabling delta.enableChangeDataFeed closes that gap: operations that
modify existing rows write additional CDC files into a
_change_data/ directory, carrying a _change_type of
insert, update_preimage, update_postimage or
delete, plus _commit_version and
_commit_timestamp. Two efficiency details matter in practice:
- Pure appends write no CDC files. Insert-only changes are derived from the files added in the log, so append-heavy bronze carries almost no CDF overhead — the write amplification lands on updates, deletes and merges.
- Maintenance emits nothing. OPTIMIZE, compaction and VACUUM are not logically data-changing, so they produce no change rows. Your incremental consumer will not see phantom churn after a compaction job (proven in the companion notebook).
| Mode | Who tracks the position | When to use it |
|---|---|---|
| Batch / manual | You do — persist the last processed version in a watermark table | Full control of batching, ordering, error handling and recovery. The pattern shown below. |
| Structured Streaming | The checkpoint | Continuous processing. Note that streaming over Delta already handles append-only increments without CDF — you add CDF when updates and deletes must propagate too. |
| Materialized Lake View | Fabric does | Genuinely automatic: Fabric's decision engine picks incremental, full or skip refresh and detects changes through CDF. Incremental refresh requires CDF on the source tables — without it the engine can only choose skip or full. Constraints: optimal refresh applies only to MLVs defined in Spark SQL (PySpark-defined MLVs always full-refresh), and non-Delta sources always full-refresh. |
Pattern: CDF deltas without MLV, watermarked in Fabric SQL Database
When you need control MLV does not give you — custom batching, bespoke error handling,
or a PySpark transform — CDF plus a watermark table is the durable equivalent. The
watermark lives in
Fabric SQL Database alongside the rest of the
metadata (etl_cdf_watermark: entity, source table,
last_version, run id, timestamp), upserted forward-only so concurrent
runs can never rewind it.
changes = (spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", 0) # every version ever
.load(SILVER))
changes = changes.filter(
changes._commit_version > last_version) # filtered AFTER reading
changes = (spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", last_version + 1) # exclusive of applied
.option("endingVersion", target_version) # bounded batch
.load(SILVER)
.where(F.col("_change_type") != "update_preimage")
.where("status = 'complete'") # pushes into the scan
.select("order_id","order_date","amount","status",
"_change_type","_commit_version")) # narrow ReadSchema
startingVersion/endingVersion are
source options, not filters: Delta uses them to decide which commits and
CDC files to open at all — files outside the window are never read. Predicates and
projection then push down within that window. Verified in the plan:
PushedFilters: [EqualTo(status,complete),
Not(EqualTo(_change_type,update_preimage))].
# one MERGE per change row - a key updated 3 times
# is written 3 times, and ordering bugs are silent
for batch in changes.collect():
apply(batch)
w = Window.partitionBy("order_id").orderBy(
F.col("_commit_version").desc())
net = changes.withColumn("rn", F.row_number().over(w)) \
.where("rn = 1").drop("rn")
# second pushdown: only the grains this batch touched
touched = [r.order_date for r in
net.select("order_date").distinct().collect()]
recomputed = (spark.read.format("delta").load(SILVER)
.where(F.col("order_date").isin(touched)
& (F.col("status") == "complete"))
.groupBy("order_date")
.agg(F.sum("amount").alias("revenue")))
target_tbl.alias("t").merge(recomputed.alias("s"),
"t.order_date = s.order_date") \
.whenMatchedUpdateAll().whenNotMatchedInsertAll().execute()
set_watermark(entity, SILVER, target_version, net.count())
isin(touched) predicate prunes the scan to those files. The
watermark advances only after the MERGE succeeds, so a failure re-processes rather
than skips.
_change_data/ is subject to
VACUUM retention (default 7 days), and log versions age out too. A consumer that
stalls longer than retention holds a watermark that can no longer be read — and the
failure mode you want is a loud fallback to full refresh, not a silent gap.
Guard it explicitly:
earliest = min(h.version for h in
spark.sql(f"DESCRIBE HISTORY delta.`{path}`").collect())
if last_version + 1 < earliest:
raise LookupError(f"watermark {last_version} predates earliest retained "
f"version {earliest} - CDF aged out; full refresh required")
current_version - last_version growing, and watermark age approaching
your retention window. Also remember CDF is not retroactive — it records
changes only from the commit where it was enabled, so turn it on before you need it.
Why your MLV keeps doing a full refresh — and the programmatic equivalent
MLV's Optimal Refresh picks between skip, incremental and full based on what changed and what the definition can express — and it silently downgrades to full rather than failing, which is convenient until it becomes invisible. Five independent conditions force full refresh; only one of them is "unsupported SQL constructs":
| Condition | Detail |
|---|---|
| Any source lacks CDF |
delta.enableChangeDataFeed=true is required on every source
table referenced by the MLV. Without it on even one, optimal refresh can only
choose between skip and full — incremental is off the table entirely.
|
| Defined in PySpark | Optimal refresh applies only to MLVs defined in Spark SQL. A PySpark-defined MLV always full-refreshes, regardless of what the logic does. |
| Non-Delta source | Any source that is not a Delta table forces full refresh for that MLV, full stop. |
| Unsupported SQL construct | Window functions and non-deterministic functions are the documented examples. The MLV still creates and still refreshes — it just silently falls back. This is the one your instinct already suspected. |
| Any delete or update on a source — independent of the above | The one most people never check. Incremental refresh currently requires the source to be append-only between refreshes. If a source table recorded even one DELETE or UPDATE since the last refresh, Fabric falls back to full — even with CDF enabled and a query using only supported SQL constructs. On a big table fed by upstream MERGE/UPDATE logic, this is very often the actual cause, not the SQL. |
Microsoft is piloting refresh hints with select customers to improve this —
not yet generally available. Force a full refresh deliberately (e.g. after a
correction) with
REFRESH MATERIALIZED LAKE VIEW [ws.lh.schema].MLV_Name FULL;
Diagnose which condition is firing
-- 1. CDF on every source? (run per source table)
SHOW TBLPROPERTIES silver.orders ('delta.enableChangeDataFeed');
-- 2. Deletes/updates since the last MLV refresh? (the usual culprit)
DESCRIBE HISTORY silver.orders
-- inspect `operation` for UPDATE / DELETE / MERGE since the MLV's last refresh timestamp
# 3. Was the last refresh actually incremental? Check MLV refresh history/lineage in the portal,
# or query the refresh metadata if exposed in your Fabric version - full vs incremental is recorded
# per refresh cycle.
The same pattern in pure Spark SQL
Everything above was PySpark. Here is the identical logic as %%sql —
useful in its own right since Sec 30 covers
Spark SQL as a first-class surface, and directly relevant here because
PySpark-defined MLVs always full-refresh (previous subsection) — a pure-SQL
transform is what stays eligible for MLV's optimal refresh in the first place, and is
also what you fall back to writing by hand when MLV's other constraints block it.
DECLARE / SET VAR — so the whole pattern becomes a single
SQL script with no host language at all. Spark 3.5 has no SQL-level variables, so the
watermark is read into Python and passed back in via args={} parameter
markers (Sec 30) — still parameterized, still
safe, just one line of glue instead of zero.
wm = spark.sql(
"SELECT last_version FROM ctl.cdf_watermark WHERE entity_name = :e",
args={"e": "silver_orders_to_gold"}).collect()[0][0]
target = spark.sql(
"SELECT max(version) FROM (DESCRIBE HISTORY silver_orders)"
).collect()[0][0]
CREATE OR REPLACE TEMP VIEW v_net_changes AS
SELECT order_id, order_date, amount, status
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY _commit_version DESC) AS rn
FROM table_changes('silver_orders', :wm + 1, :tgt)
WHERE _change_type != 'update_preimage' AND status = 'complete'
) WHERE rn = 1
args={"wm": wm, "tgt": target} on the final
spark.sql() call.
BEGIN
DECLARE wm BIGINT;
DECLARE tgt BIGINT;
SET VAR wm = (SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'silver_orders_to_gold');
SET VAR tgt = (SELECT max(version) FROM (DESCRIBE HISTORY silver_orders));
CREATE OR REPLACE TEMP VIEW v_net_changes AS
SELECT order_id, order_date, amount, status
FROM table_changes('silver_orders', wm + 1, tgt)
WHERE _change_type != 'update_preimage' AND status = 'complete'
QUALIFY ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY _commit_version DESC) = 1;
END
QUALIFY (available on both runtimes, shown here for contrast)
collapses the window filter into the same statement instead of a wrapping
subquery. The whole ingestion-to-merge unit is one script, reviewable as one
artifact.
The rest of the pipeline — apply and advance, in SQL either way
-- Recompute only the grains this batch touched (predicate pushes into the source scan)
CREATE OR REPLACE TEMP VIEW v_recomputed_gold AS
SELECT s.order_date, SUM(s.amount) AS revenue, COUNT(*) AS orders
FROM silver_orders s
WHERE s.status = 'complete'
AND s.order_date IN (SELECT DISTINCT order_date FROM v_net_changes)
GROUP BY s.order_date;
-- Apply as one MERGE, full business key
MERGE INTO gold_daily t
USING v_recomputed_gold s
ON t.order_date = s.order_date
WHEN MATCHED THEN UPDATE SET t.revenue = s.revenue, t.orders = s.orders
WHEN NOT MATCHED THEN INSERT (order_date, revenue, orders)
VALUES (s.order_date, s.revenue, s.orders);
-- Advance the watermark - forward-only, so concurrent/retried runs can never rewind it
MERGE INTO ctl.cdf_watermark t
USING (SELECT 'silver_orders_to_gold' AS entity_name, {target} AS new_version) s
ON t.entity_name = s.entity_name
WHEN MATCHED AND s.new_version > t.last_version
THEN UPDATE SET last_version = s.new_version, updated_at = current_timestamp();
On 3.5, {target} is a bound parameter like the earlier queries; on 4.x it
is literally the tgt session variable, referenced by name with no
substitution at all. ctl.cdf_watermark is a plain Delta control table
here so the whole example stays self-contained and portable — the equivalent using
Fabric SQL Database as the control store follows the pyodbc pattern in
Sec 36; only the watermark read/write changes,
the CDF logic above is identical either way.
%%sql cells in
nb_cdf_incremental_pattern.ipynb against the same tables as the PySpark
version, with the identical idempotency and retention-guard checks.
nb_cdf_incremental_pattern.ipynb reads the change
feed with version bounds pushed to the source,
collapses inserts/updates/deletes to net change per key — the exact case MLV's
append-only requirement excludes — recomputes only the touched grains, and MERGEs the
result, with the watermark held in Fabric SQL Database. You get incremental cost on a
table MLV would fully rebuild every cycle, at the cost of owning the orchestration MLV
would otherwise manage for you. Reach for it specifically when the diagnosis above
shows update/delete traffic on the source, or a PySpark-only transform — not as a
default replacement for MLV where MLV's constraints don't bind.
nb_cdf_incremental_pattern.ipynb executes this end
to end — watermark table, version-bounded reads with the pushdown asserted from the
physical plan, net-change collapsing, MERGE, an idempotency check (second run
processes zero rows), proof that OPTIMIZE emitted no change rows, and the retention
guard firing on a stale watermark.
Interactive: table optimization advisor
inferSchema in production paths.
Column Statistics, Data Skipping & SQL-to-Spark Tuning Masterclass
In traditional relational databases (SQL Server, Oracle, Snowflake), 90% of performance engineering revolves around index seeks, query plan operators, updating statistics, and query decomposition. Over OneLake Delta tables in Microsoft Fabric, achieving sub-second query performance and 10× compute cost reductions requires mastering the 3 tiers of lakehouse statistics, eliminating Delta Transaction Log skipping blind spots, diagnosing Native Execution Engine (NEE / Velox) fallbacks from Spark logs, and systematically refactoring legacy SQL into high-throughput distributed Spark SQL patterns.
_delta_log/*.json &
*.checkpoint.parquet
• numRecords: 1,500,000
• minValues: {"id": 1, "date": "2026-01-01"}
• maxValues: {"id": 1500, "date": "2026-08-17"}
• nullCount: {"id": 0, "status": 12}
part-*.parquet file
• Row Group 1 (128 MB / 1M rows)
• Dictionary page column encodings
• Column index & offset index
• Covers ALL columns (no 32-col limit)
• Table row count & total raw byte size
• Number of Distinct Values (NDV)
• Equi-width value histograms
• Average column width & null counts
ANALYZE TABLE.
BroadcastHashJoin vs
ShuffledHashJoin prior to physical planning.
1. The 32-Column Data Skipping Trap in the Delta Transaction Log
A critical distinction in Lakehouse architecture:
The 32-column ceiling applies strictly to the Delta Transaction Log
(_delta_log), NOT to the underlying Parquet files!
By default, Delta Lake writers only collect and serialize min/max statistics in the
transaction log for the first 32 columns of a table schema (delta.dataSkippingNumIndexedCols = 32). If your wide fact table has 60 columns and your queries filter on column 35 (e.g.
store_region, tenant_id, event_status, or
is_active),
Delta cannot skip a single Parquet file at the driver level. The driver assumes
all files could contain matching records and schedules tasks to scan every file in
OneLake!
_delta_log/00000X.json. Because tenant_id is column #35,
its minValues and maxValues are NULL in the log.➔ Driver Outcome: 0% File Skipping (Driver schedules tasks to scan 10,000 Parquet files from OneLake).
Step 2 (Executor FileScan & Parquet Footer Evaluation): Executors download and open the 10,000 Parquet files. The Parquet Page Footers DO contain stats for all columns, allowing the executor to skip 128MB row groups locally.
➔ The Fatal Penalty: You still paid the full network latency, OneLake transaction costs, and task scheduling overhead of opening 10,000 files!
-- Wide table with 50 columns
CREATE TABLE silver.fact_telemetry (
device_id STRING, -- col 1 (indexed in _delta_log)
-- ... cols 2 to 32 (indexed in _delta_log) ...
col32_meta STRING, -- col 32 (indexed in _delta_log)
tenant_id STRING, -- col 33 (NOT in _delta_log! min/max omitted)
status STRING -- col 34 (NOT in _delta_log! min/max omitted)
) USING DELTA;
-- 💥 SILENT FULL TABLE SCAN AT DRIVER LEVEL:
-- Driver reads ALL 10,000 files because tenant_id stats are missing in _delta_log!
SELECT * FROM silver.fact_telemetry WHERE tenant_id = 'TENANT_99';
-- OPTION A: Target only high-cardinality filter columns (Best Practice)
ALTER TABLE silver.fact_telemetry SET TBLPROPERTIES (
'delta.dataSkippingStatsColumns' = 'device_id, tenant_id, status, event_timestamp'
);
-- OPTION B: Raise the global indexed column ceiling
ALTER TABLE silver.fact_telemetry SET TBLPROPERTIES (
'delta.dataSkippingNumIndexedCols' = '64'
);
-- Backfill stats for existing historical Parquet files:
ANALYZE TABLE silver.fact_telemetry COMPUTE DELTA STATISTICS;
delta.dataSkippingStatsColumns keeps transaction log JSON
compact while ensuring exact min/max bounds are indexed in
_delta_log for high-value filter columns.
2. How to Genuinely Utilise Spark Logs & Metrics to Diagnose Gaps
When a Spark job is slow or consuming excessive Capacity Units (CUs) in Fabric, do not guess. Inspect the Fabric Monitoring Hub, Spark History Server SQL Graph, and Driver Logs (stderr) for these quantifiable physical signatures:
| Diagnostic Target | Where to Look in Logs / UI | Physical Signature / Symptom | Root Cause & Immediate Remediation |
|---|---|---|---|
| 0% Data Skipping (Full Table Scan) |
Spark UI ➔ SQL Tab ➔ Click FileScan parquet node (or
DESCRIBE HISTORY table).
|
number of files read (12,450) equals total files in table;
scan time dominates job duration.
|
Filter predicate is past column 32 in Delta log, or data is randomly sorted
without clustering. ➔ Set delta.dataSkippingStatsColumns + run
OPTIMIZE table CLUSTER BY (filter_col).
|
| Native Execution (NEE) Fallback |
Driver stderr / Livy Log ➔ search for Gluten or
VeloxColumnarToRowExec.
|
Physical plan contains alternating VeloxColumnarToRow and
RowToVeloxColumnar operators; CPU usage spikes.
|
A non-vectorized Python UDF (@udf) or unsupported complex
expression forced a fallback to the slow Java VM.➔ Rewrite using built-in Spark SQL functions or Higher-Order array transforms. |
| Severe Partition Data Skew | Spark UI ➔ Stages Tab ➔ Expand Task Duration Metrics (Min / 25th / Median / 75th / Max). | Median task duration = 1.8s; 75th percentile = 2.1s; Max task duration = 42 minutes. Single task processes 90% of rows. |
Heavy skew on join/group key (e.g. NULL or default dummy ID).➔ Enable AQE Skew Join ( spark.sql.adaptive.skewJoin.enabled=true) or
apply explicit key salting.
|
| SortMergeJoin Memory Spill |
Spark UI ➔ Stages Tab ➔ Aggregated Metrics ➔
Spill (Memory) & Spill (Disk).
|
Spill (Memory): 140 GB, Spill (Disk): 38 GB. Executor
NVMe drives saturated with temp spill files.
|
Join threshold misestimate or shuffle partitions too coarse. ➔ Lower partition advisory size ( spark.sql.adaptive.advisoryPartitionSizeInBytes=67108864) or force
BroadcastHashJoin if dimension < 256MB.
|
| Small-File Task Explosion | Spark UI ➔ Jobs Tab ➔ Total tasks count for scan stage > 20,000 tasks lasting < 150ms each. | Task scheduling overhead (Livy/Driver RPC) takes 80% of stage time; average file size < 8 MB. |
Micro-batch streaming appends without compaction. ➔ Run OPTIMIZE table to consolidate into 500MB Parquet files with
V-Order.
|
3. SQL-to-Spark Query Rewriting Masterclass (5 Core Production Patterns)
Migrating traditional T-SQL / Oracle scripts directly into Spark SQL without refactoring frequently leads to 10× slower execution. Here are the 5 architectural query refactorings every data engineer must apply:
Pattern A: Eliminating Multi-Scan CTE Traps (The Recomputation Vulnerability)
In SQL Server and Oracle, CTEs (WITH cte AS (...)) are often materialized
or referenced efficiently. In Apache Spark,
CTEs and DataFrames are lazily evaluated abstract syntax trees (ASTs). If a CTE
is referenced 3 times in downstream joins or unions, Spark parses and
executes the entire scan, filter, and aggregation 3 separate times from
OneLake!
WITH AggregatedSales AS (
-- Heavy scan over 500M rows with group by
SELECT cust_id, SUM(amount) AS total_spend, COUNT(*) AS orders
FROM silver.fact_orders
GROUP BY cust_id
)
-- 💥 DISASTER: AggregatedSales is re-scanned and re-aggregated 3 separate times!
SELECT c.cust_name, a1.total_spend
FROM dim_customer c
JOIN AggregatedSales a1 ON c.cust_id = a1.cust_id
LEFT JOIN AggregatedSales a2 ON c.parent_cust_id = a2.cust_id;
# 1. Compute heavy aggregation ONCE and break Catalyst lineage graph
df_agg = (spark.table("silver.fact_orders")
.groupBy("cust_id")
.agg(F.sum("amount").alias("total_spend"), F.count("*").alias("orders"))
.localCheckpoint(eager=True)) # Cut DAG & persist in executor NVMe cache
df_agg.createOrReplaceTempView("agg_sales")
# 2. Downstream query reads pre-materialized buffer from NVMe
spark.sql("""
SELECT c.cust_name, a1.total_spend
FROM dim_customer c
JOIN agg_sales a1 ON c.cust_id = a1.cust_id
LEFT JOIN agg_sales a2 ON c.parent_cust_id = a2.cust_id
""")
Pattern B: Replacing Python UDFs with Native Velox Vectorized Expressions
Standard Python UDFs (@udf) require serializing rows from JVM off-heap
memory through a local Unix socket to a spawned Python worker daemon. This introduces
severe serialization overhead, completely breaks Whole-Stage CodeGen, and
forces the Native Execution Engine (Velox) to fall back to the slow Java VM.
from pyspark.sql.functions import udf
# 💥 Slow: 1 IPC serialization roundtrip PER ROW
@udf("string")
def clean_phone(phone):
if not phone: return None
return "".join([c for c in phone if c.isdigit()])
df.withColumn("clean_phone", clean_phone("raw_phone"))
from pyspark.sql import functions as F
# ✓ 25× Faster: Executes directly in Velox C++ SIMD registers with ZERO serialization!
df_clean = df.withColumn("clean_phone", F.regexp_replace(F.col("raw_phone"), r"\D", ""))
# Explain plan verification receipt:
# +- ProjectExecTransformer [regexp_replace(raw_phone#1, \D, ) AS clean_phone#2]
Pattern C: Resolving Skew Joins via AQE Heuristics & Key Salting
When a join key contains extreme skew (e.g. 50,000,000 rows with
cust_id = NULL or a default placeholder ID), a single reducer task
receives gigabytes of data and runs for 45 minutes while the remaining 199 tasks
finish in 2 seconds.
-- Step 1: Add a random salt (0-9) to the skewed large table
CREATE OR REPLACE TEMP VIEW v_orders_salted AS
SELECT *, CONCAT(cust_id, '_', CAST(FLOOR(RAND() * 10) AS INT)) AS salted_join_key
FROM silver.fact_orders;
-- Step 2: Explode the small dimension table 10× to match all possible salt keys
CREATE OR REPLACE TEMP VIEW v_dim_customer_replicated AS
SELECT c.*, CONCAT(c.cust_id, '_', salt_id) AS salted_join_key
FROM dim_customer c
LATERAL VIEW EXPLODE(ARRAY(0,1,2,3,4,5,6,7,8,9)) t AS salt_id;
-- Step 3: Join on salted key -> Skew is perfectly distributed across 10 parallel tasks!
SELECT o.order_id, c.cust_name, o.amount
FROM v_orders_salted o
JOIN v_dim_customer_replicated c ON o.salted_join_key = c.salted_join_key;
Pattern D: Single-Pass Window Functions vs. Multi-Scan Self-Joins (Top-N per Group)
A classic SQL pattern for finding the most recent record per entity is joining a table
back to a grouped subquery on MAX(date). In Spark, this causes
2 full table scans, 2 shuffles, and an expensive SortMergeJoin. A single-pass
window function cuts I/O by 50% and executes via native Velox
WindowExecTransformer.
-- 💥 Reads fact_orders TWICE + performs SortMergeJoin
SELECT o.*
FROM silver.fact_orders o
JOIN (
SELECT cust_id, MAX(order_date) AS max_dt
FROM silver.fact_orders
GROUP BY cust_id
) m ON o.cust_id = m.cust_id AND o.order_date = m.max_dt;
-- ✓ Scans OneLake exactly ONCE with native Velox WindowExecTransformer
WITH RankedOrders AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY cust_id ORDER BY order_date DESC) AS rnk
FROM silver.fact_orders
)
SELECT * EXCEPT(rnk)
FROM RankedOrders
WHERE rnk = 1;
4. Production Diagnostic Workbook (Run Directly in Fabric Notebook)
Use this automated diagnostic script to audit table skipping health, file fragmentation, deletion vector overhead, and column stats coverage across your Lakehouse:
from pyspark.sql import functions as F
def audit_delta_table_health(table_name):
"""Comprehensive health check for Data Skipping, Small Files & Deletion Vectors"""
print(f"=== AUDITING TABLE: {table_name} ===")
# 1. Inspect Physical File Sizing & Distribution
df_files = spark.sql(f"SELECT * FROM {table_name}.`$files`")
file_metrics = df_files.select(
F.count("*").alias("total_files"),
F.round(F.sum("size_in_bytes") / (1024**3), 2).alias("total_gb"),
F.round(F.avg("size_in_bytes") / (1024**2), 2).alias("avg_file_mb"),
F.sum(F.when(F.col("size_in_bytes") < 32*1024*1024, 1).otherwise(0)).alias("small_files_lt_32mb"),
F.sum(F.when(F.col("deletionVector").isNotNull(), 1).otherwise(0)).alias("files_with_dvs")
).collect()[0]
print(f" • Total Parquet Files: {file_metrics['total_files']:,}")
print(f" • Total Table Size: {file_metrics['total_gb']} GB")
print(f" • Average File Size: {file_metrics['avg_file_mb']} MB (Target: 400-500 MB)")
print(f" • Fragmented Small Files (<32MB): {file_metrics['small_files_lt_32mb']:,}")
print(f" • Files with Deletion Vectors: {file_metrics['files_with_dvs']:,}")
# 2. Check Table Properties for Data Skipping Indexing in _delta_log
df_props = spark.sql(f"SHOW TBLPROPERTIES {table_name}").collect()
props_map = {row['key']: row['value'] for row in df_props}
stats_cols = props_map.get('delta.dataSkippingStatsColumns', '[NOT SET - Defaults to first 32 cols in _delta_log]')
print(f" • Delta Log Indexed Columns: {stats_cols}")
# Actionable Recommendations
if file_metrics['small_files_lt_32mb'] > 100:
print(f" 🚨 RECOMMENDATION: Run 'OPTIMIZE {table_name}' to compact small files.")
if file_metrics['files_with_dvs'] > (file_metrics['total_files'] * 0.25):
print(f" 🚨 RECOMMENDATION: Run 'REORG TABLE {table_name} APPLY (PURGE)' to purge soft deletion vectors.")
# Run audit across lakehouse tables:
audit_delta_table_health("silver.orders")
Conditional Lakehouse Table Optimization & Health Checks Framework
Running blind, fixed-schedule OPTIMIZE commands across hundreds of
Lakehouse tables burns massive Microsoft Fabric Capacity Units (CUs) on tables that
are already healthy. The Microsoft Fabric
"Check-Then-Act" Conditional Optimization Pattern uses the built-in
sys.sp_get_table_health_metrics stored procedure on the SQL Analytics
Endpoint to inspect physical table health first, triggering Spark
OPTIMIZE and REORG only when anomalies are detected.
'dbo.FactSales'
PotentialAnomalyType and file
distribution metrics.
.output.resultSets[0].rows[0]
['PotentialAnomalyType']), 0)
PotentialAnomalyType > 0 (anomaly detected) ➔ Route to
True Branch.
spark.sql(f"OPTIMIZE {table_name}")
1. Potential Anomaly Types Decoded
The sys.sp_get_table_health_metrics procedure analyzes the underlying
Delta log and file storage, classifying table health into structured anomaly codes:
| Anomaly Code | Anomaly Description | Physical Degradation Trigger | Recommended Automated Action |
|---|---|---|---|
| Type 0 (Clean) | Healthy / Balanced Table | Average file size between 300MB–500MB; deletion vector overhead < 5%. | No-op: Pipeline skips optimization entirely, consuming 0 Spark CUs. |
| Type 1 (Small Files) | Excessive Small-File Count | Table contains >1,000 fragmented files under 32MB from streaming or micro-batch appends. |
Run OPTIMIZE {table} (optionally with
ZORDER BY / CLUSTER BY).
|
| Type 2 (DV Bloat) | High Deletion Vector Ratio |
More than 20% of files have active deletion vector bitmaps from heavy
UPDATE/DELETE/MERGE operations.
|
Run
REORG TABLE {table} APPLY (PURGE); VACUUM {table} RETAIN 168 HOURS;
|
| Type 3 (Skewed Files) | High File-Size Variance | Extreme disparity between smallest file (<1MB) and largest file (>2GB). |
Run OPTIMIZE {table} to balance file sizes into uniform 500MB
chunks.
|
2. Reusable Multi-Table Automated Maintenance Suite (PySpark)
For automated, catalog-wide maintenance across all tables in a Fabric Lakehouse, use this production PySpark workbook. It inspects table health metrics dynamically and dispatches maintenance tasks only where needed:
from pyspark.sql import functions as F
def optimize_lakehouse_tables_conditionally(database_name="dbo", small_file_threshold_mb=32, dv_ratio_threshold=0.20):
"""
Scans all Delta tables in the database, audits health metrics,
and executes OPTIMIZE / REORG only when degradation is detected.
"""
tables = [row.tableName for row in spark.sql(f"SHOW TABLES IN {database_name}").collect()]
print(f"Auditing {len(tables)} tables in database '{database_name}'...")
for tbl in tables:
full_table = f"{database_name}.{tbl}"
try:
# 1. Read Delta $files metadata
df_files = spark.sql(f"SELECT * FROM {full_table}.`$files`")
metrics = df_files.select(
F.count("*").alias("total_files"),
F.round(F.avg("size_in_bytes") / (1024**2), 2).alias("avg_file_mb"),
F.sum(F.when(F.col("size_in_bytes") < small_file_threshold_mb*1024*1024, 1).otherwise(0)).alias("small_files"),
F.sum(F.when(F.col("deletionVector").isNotNull(), 1).otherwise(0)).alias("files_with_dv")
).collect()[0]
total_files = metrics["total_files"]
small_files = metrics["small_files"]
dv_files = metrics["files_with_dv"]
needs_optimize = (small_files > 50) or (total_files > 200 and metrics["avg_file_mb"] < 100)
needs_purge = (total_files > 0) and ((dv_files / total_files) >= dv_ratio_threshold)
if needs_optimize or needs_purge:
print(f"⚡ MAINTAINING {full_table}: Total={total_files}, Small={small_files}, DV={dv_files}")
if needs_purge:
print(f" -> Purging Deletion Vectors on {full_table}...")
spark.sql(f"REORG TABLE {full_table} APPLY (PURGE)")
if needs_optimize:
print(f" -> Running V-Order Compaction on {full_table}...")
spark.sql(f"OPTIMIZE {full_table}")
else:
print(f"✓ SKIPPING {full_table} (Healthy: avg {metrics['avg_file_mb']} MB, 0 DV bloat)")
except Exception as e:
print(f"⚠️ Could not audit {full_table}: {str(e)}")
# Run automated conditional maintenance across Lakehouse:
optimize_lakehouse_tables_conditionally("dbo")
Lakehouse Data Modeling Masterclass: Data Vault 2.0 (Silver) & Kimball Dimensional (Gold) Side-by-Side
Modern enterprise Lakehouses face a classic architectural conflict: Data integration requires resilience against schema drift, multi-source ingestion concurrency, and complete historical auditability, while BI and analytics require fast, intuitive star schemas optimized for Power BI Direct Lake mode. Here is how Data Vault 2.0 (in the Silver Layer) and Kimball Dimensional Modeling (in the Gold Layer) sit side-by-side seamlessly over OneLake Delta tables.
• Schema-on-read leniency
• Ingestion metadata (
_load_dts, _source_file)• Replayable landing Delta tables
• Links: Multi-table relationships & transactions
• Satellites: Historical context & change tracking
• Stored as physical Delta tables for scalable ingestion
• Dimensions: SCD Type 1 & SCD Type 2
• Facts: Transactional & Periodic Snapshots
• V-Order enabled for sub-second Direct Lake
1. How Data Vault 2.0 & Kimball Dimensional Sit Side-by-Side
| Architectural Dimension | Silver Layer — Data Vault 2.0 (System of Record) | Gold Layer — Kimball Dimensional (Information Marts) |
|---|---|---|
| Primary Purpose | System of Record & Multi-Source Integration: Decouples raw business keys from source schema changes; 100% auditability and zero-loss history. | Business Consumption & BI: Star schema optimized for Power BI Direct Lake semantic models, self-service analysts, and sub-second aggregation. |
| Core Entities | Hubs (Distinct Keys), Links (Relationships/Joins), Satellites (Attributes & History with HashDiffs), PIT / Bridge tables. | Fact Tables (Measures & Foreign Keys), Conformed Dimensions (SCD Type 1 & Type 2 with BIGINT Surrogate Keys). |
| Key Datatypes & Mechanism | Deterministic Hash Keys (STRING SHA-256): Enables independent, parallel asynchronous ingestion from 10+ sources without cross-source surrogate key lookups. | BIGINT / INT (64-bit / 32-bit Integers): MANDATORY FOR GOLD. 64-bit integer keys allow VertiPaq dictionary vectorization, SIMD CPU registers, and 80% lower RAM usage than string keys in Direct Lake mode. |
| Physical Delta Table Tuning |
• delta.enableDeletionVectors = true• delta.enableChangeDataFeed = true• V-Order OFF (saves CPU write overhead during high-frequency ingestion micro-batches). |
• delta.parquet.vorder.enabled = true (Mandatory for Power BI
Direct Lake)• Liquid Clustering: CLUSTER BY (date_id, customer_sk)• Target Parquet file size: 400–500 MB. |
2. Silver Hubs & Links: Physical Delta Tables vs. Materialized Lakehouse Views (MLVs) as Data Scales
A critical architectural decision in Microsoft Fabric: Should Hubs and Links be stored as physical Delta tables or defined as Materialized Lakehouse Views (MLVs)?
| Implementation Approach | How It Operates Internally | Scalability Over Time (1B+ Rows) | Verdict & Best Practice |
|---|---|---|---|
| Physical Delta Tables (Recommended for Hubs & Links) |
Hubs and Links are written as physical Delta tables using MERGE or
INSERT IF NOT EXISTS with Change Data Feed (CDF).
|
Highly Scalable: Ingestion only evaluates incoming micro-batch records
against the existing Delta log index. Supports Liquid Clustering (CLUSTER BY (hk)) and sub-second point lookups.
|
⭐ BEST PRACTICE FOR SILVER HUBS & LINKS: Guarantees deterministic, fast incremental ingestion without re-scanning raw Bronze history. |
| Materialized Lakehouse Views (MLVs) |
Defined via SQL query (CREATE MATERIALIZED VIEW ...). Automatically
maintained by Fabric's background refresh engine upon upstream Bronze commits.
|
Moderate Scalability: Background refresh engine manages incremental state. However, complex multi-link joins can experience refresh compute overhead at massive scale. | IDEAL FOR POINT-IN-TIME (PIT) & BRIDGE TABLES: Perfect for joining Hubs + Links + Satellites into intermediate queryable views without manual orchestrations. |
| Virtual SQL Views (Unmaterialized) |
Standard SQL CREATE VIEW ... AS SELECT DISTINCT ... FROM bronze.
|
Fails at Scale: Every query against the Hub forces a full scan of all historical Bronze files from scratch. Cannot be queried via Power BI Direct Lake mode. | ANTI-PATTERN: Never use virtual views for high-volume Silver Hubs or Gold analytical tables. |
3. Why Gold Star Schema Surrogate Keys MUST Be BIGINT (Not String UUIDs)
In Power BI Direct Lake mode and Polaris Distributed Query Execution, joining Fact and Dimension tables on 32-character MD5 / SHA-256 strings is a fatal performance anti-pattern:
• BIGINT 64-bit Integer Keys (8 bytes per row / xxhash64): VertiPaq bit-packs integers down to 4–8 bytes. The same 500M row foreign key column consumes only 2.4 GB of memory (85% reduction), evaluates joins using native CPU SIMD registers, and stays 100% within Direct Lake memory limits.
4. Production Implementation: Silver Data Vault to High-Performance Gold Star Schema
Here is the production-grade Spark SQL pattern generating Silver Data Vault Delta tables and transforming them into an optimal Gold Star Schema with `BIGINT` surrogate keys, V-Order enabled, and Liquid Clustering:
-- 1. SILVER HUB: Physical Delta table with SHA-256 Hash Key for Ingestion Concurrency
CREATE TABLE IF NOT EXISTS silver.hub_customer (
customer_hk STRING NOT NULL, -- SHA-256(customer_id) for multi-source ingest
customer_id STRING NOT NULL,
load_dts TIMESTAMP NOT NULL,
record_source STRING NOT NULL
) USING DELTA
CLUSTER BY (customer_hk)
TBLPROPERTIES (
'delta.enableChangeDataFeed' = 'true',
'delta.enableDeletionVectors' = 'true'
);
-- 2. SILVER SATELLITE: Attribute History with HashDiffs
CREATE TABLE IF NOT EXISTS silver.sat_customer_profile (
customer_hk STRING NOT NULL,
load_dts TIMESTAMP NOT NULL,
hash_diff STRING NOT NULL, -- SHA-256(name + email + tier + city)
customer_name STRING,
customer_email STRING,
customer_tier STRING,
city STRING,
record_source STRING NOT NULL
) USING DELTA
CLUSTER BY (customer_hk);
-- 3. GOLD CONFORMED DIMENSION (SCD Type 2): Optimal for Power BI Direct Lake
CREATE OR REPLACE TABLE gold.dim_customer
USING DELTA
CLUSTER BY (customer_id, is_current)
TBLPROPERTIES (
'delta.parquet.vorder.enabled' = 'true', -- Mandatory for Direct Lake
'delta.dataSkippingStatsColumns' = 'customer_sk, customer_id, customer_tier, is_current'
) AS
WITH CustomerHistory AS (
SELECT
h.customer_id,
s.customer_name,
s.customer_email,
s.customer_tier,
s.city,
s.load_dts AS valid_from,
LEAD(s.load_dts) OVER (PARTITION BY h.customer_hk ORDER BY s.load_dts ASC) AS valid_to
FROM silver.hub_customer h
JOIN silver.sat_customer_profile s ON h.customer_hk = s.customer_hk
)
SELECT
-- ✓ FASTEST DESIGN: 64-bit BIGINT hash surrogate key (8 bytes vs 32-byte string)
CAST(xxhash64(CONCAT(customer_id, '_', CAST(valid_from AS STRING))) AS BIGINT) AS customer_sk,
customer_id,
customer_name,
customer_email,
customer_tier,
city,
valid_from,
COALESCE(valid_to, TIMESTAMP'9999-12-31 23:59:59') AS valid_to,
CASE WHEN valid_to IS NULL THEN 1 ELSE 0 END AS is_current
FROM CustomerHistory;
-- 4. GOLD FACT TABLE: Joining on BIGINT surrogate keys for maximum throughput
CREATE OR REPLACE TABLE gold.fact_orders
USING DELTA
CLUSTER BY (order_date_id, customer_sk)
TBLPROPERTIES ('delta.parquet.vorder.enabled' = 'true') AS
SELECT
o.order_id,
CAST(DATE_FORMAT(o.order_date, 'yyyyMMdd') AS INT) AS order_date_id,
c.customer_sk, -- BIGINT foreign key
o.order_amount,
o.tax_amount,
o.discount_amount
FROM silver.fact_orders_raw o
JOIN gold.dim_customer c
ON o.customer_id = c.customer_id
AND o.order_date >= c.valid_from
AND o.order_date < c.valid_to;
Fabric Warehouse, SQL Endpoint & Fabric SQL Database — Polaris & SQL Engine Internals & Performance Tuning
Spark is not the only engine querying OneLake Delta tables. The Fabric Data Warehouse and the SQL analytics endpoint share Microsoft's distributed, serverless T-SQL engine — Polaris — while Fabric SQL Database brings a full-featured operational SQL database with autonomous Delta mirroring. Achieving sub-second response times and eliminating latency requires mastering DQP distributed execution, diagnosing bottlenecks with PDW DMVs, optimizing statistics, and tuning query execution plans.
1. Polaris Distributed Architecture
| Component | Architectural Mechanism & Role |
|---|---|
| SQL Front End (SQL-FE) | Entry point for TDS (port 1433) client connections (SSMS, Power BI, Azure Data Studio, pyodbc). Handles Entra ID / RBAC authentication, AST compilation, metadata catalog resolution, and initial logical query plan generation. |
| Distributed Query Processor (DQP) |
The distributed brain of Polaris. Evaluates data volume, estimates join
cardinalities from statistics, decomposes the logical query into a DAG of
physical sub-queries (dqp_steps), and coordinates distributed data
movement (ShuffleMove, BroadcastMove).
|
| Polaris Compute Pool | Stateless backend compute nodes, each equipped with dedicated CPU, high-speed RAM, and NVMe SSD local caching. Each node runs an Execution Service and a single-node C++ Query Execution engine derived from the SQL Server relational engine. |
| Data Cells (Storage Abstraction) | The foundational unit of data management in Polaris. A dataset in OneLake is partitioned into logical "cells" (groups of Parquet files). The DQP assigns cells dynamically to backend compute nodes for parallel execution. |
| Stateless Durability & Failover | All state and metadata reside externally in OneLake Delta logs and centralized metadata services. If a compute node fails mid-query, the Topology Manager reassigns its assigned cells to healthy nodes in the topology with zero data loss. |
spark.conf to set. Where Spark performance is largely an
engine configuration and memory tuning challenge (Sec 14), Warehouse performance is strictly a
data modelling, statistics, and SQL query structure challenge.
2. Interactive: How a Warehouse / SQL Endpoint Query Executes Under the Hood
sys.dm_exec_sessions for client library version, Entra tenant ID, and
active connection properties.
3. How to Resolve Performance Issues in Fabric Warehouse & SQL Analytics Endpoint
When T-SQL queries run slow in Fabric Warehouse or the SQL Analytics Endpoint, use this systematic diagnostic workflow to identify the root cause across Result Caching, Distributed Data Movement, Statistics Quality, and OneLake File Compaction:
| Performance Symptom | Diagnostic DMV / Query | Physical Mechanism & Root Cause | Remediation & Optimization Strategy |
|---|---|---|---|
| Slow Distributed Joins (Shuffle Bottleneck) |
Query sys.dm_pdw_request_steps filtering by
request_id. Check for
operation_type = 'ShuffleMoveOperation'.
|
DQP is redistributing millions of rows across Polaris compute nodes because join keys are not clustered or statistics are missing. |
1. Push down filters before the join. 2. Create statistics on join columns: CREATE STATISTICS s_col ON tbl(col);3. Pre-aggregate intermediate data into a CREATE TABLE ... AS SELECT (CTAS) staging table.
|
| Result Cache Misses (High Compute Costs) | Check execution times for repeated queries. If duration is identical, Result Set Caching is bypassed. |
Non-deterministic T-SQL functions (e.g. GETDATE(),
NEWID(), RAND()), Row-Level Security (RLS) policies,
or active Delta writes invalidate the cache.
|
1. Replace GETDATE() with a deterministic parameter passed from the
client application.2. Maintain read-heavy analytical views as deterministic queries. |
| Small-File Scan Latency (File Proliferation) |
Query sys.dm_pdw_sql_requests. Check
total_elapsed_time on individual compute nodes reading OneLake.
|
Underlying Delta table contains thousands of <10MB fragmented Parquet files, forcing Polaris QE nodes to issue hundreds of thousands of HTTP GET calls to OneLake. |
Run OPTIMIZE table in Spark or utilize Fabric Auto-Compaction to
merge small files into optimal 400–500 MB V-Order Parquet files.
|
| Stale / Missing Statistics (Wrong Plan Choice) |
Query sys.stats and sys.stats_columns; check
STATS_DATE().
|
Polaris relies on statistics to choose between BroadcastMove (cheap
replicate) and ShuffleMove (expensive repartition). Missing stats
force conservative, slow plans.
|
Execute UPDATE STATISTICS schema.table WITH FULLSCAN; on
high-volume fact and dimension tables after large batch loads.
|
| Procedural Cursor & Loop Latency |
Inspect sys.dm_exec_requests. Thousands of individual short-lived
requests generated sequentially.
|
Using T-SQL WHILE loops or row-by-row cursors causes massive DQP
scheduling latency because each iteration submits an independent distributed
query.
|
Refactor procedural loops into set-based T-SQL operations using
MERGE, window functions (ROW_NUMBER()), or batch CTAS
statements.
|
-- 1. Identify Currently Running & Slowest Recent Distributed Queries
SELECT TOP 15
r.request_id,
r.status,
r.submit_time,
r.total_elapsed_time / 1000.0 AS elapsed_seconds,
r.command
FROM sys.dm_pdw_exec_requests r
ORDER BY r.total_elapsed_time DESC;
-- 2. Trace Distributed DQP Steps for a Specific Bottlenecked Query
-- Replace 'QID12345' with the request_id from query #1
SELECT
s.request_id,
s.step_index,
s.operation_type, -- Look for 'ShuffleMoveOperation' vs 'BroadcastMoveOperation'
s.total_elapsed_time / 1000.0 AS step_seconds,
s.command
FROM sys.dm_pdw_request_steps s
WHERE s.request_id = 'QID12345'
ORDER BY s.step_index ASC;
-- 3. Audit Outdated or Missing Statistics Across All Warehouse Tables
SELECT
SCHEMA_NAME(t.schema_id) AS schema_name,
t.name AS table_name,
s.name AS stats_name,
STATS_DATE(s.object_id, sstats_id) AS last_updated,
s.auto_created,
s.user_created
FROM sys.stats s
JOIN sys.tables t ON s.object_id = t.object_id
ORDER BY last_updated ASC;
4. Resolving Performance Issues in Fabric SQL Database (Operational Engine)
Fabric SQL Database brings the full-featured, transactional Azure SQL Database engine into Microsoft Fabric with Autonomous Mirroring to OneLake Delta tables. Because it powers high-concurrency transactional OLTP workloads, performance tuning differs fundamentally from distributed Polaris warehouses:
| Diagnostic Focus | Where to Look in Fabric SQL DB | Physical Bottleneck & Symptom | Remediation & Optimization Strategy |
|---|---|---|---|
| Query Plan Regressions & High CPU |
Query Store DMVs: sys.query_store_runtime_stats &
sys.query_store_plan.
|
A previously fast query suddenly consumes 90% CPU due to a sub-optimal plan compiled after data growth. |
1. Force the known-good plan via Query Store:EXEC sp_query_store_force_plan @query_id, @plan_id;2. Use OPTION (RECOMPILE) for queries with dynamic multi-variable
search criteria.
|
| Missing Index & Table Scan Latency |
Query sys.dm_db_missing_index_details and
sys.dm_db_missing_index_group_stats.
|
Frequent point-lookups and range filters scanning entire tables, causing high logical reads and buffer cache churn. |
Create covering non-clustered indexes with INCLUDE clauses:CREATE NONCLUSTERED INDEX idx_cust ON sales(cust_id) INCLUDE (order_date,
total_amount);
|
| Lock Contention & Blocking |
Inspect sys.dm_os_wait_stats for LCK_M_* waits and
query sys.dm_tran_locks.
|
Long-running transactional writes holding exclusive locks, blocking concurrent reads and causing application timeouts. | Enable Read Committed Snapshot Isolation (RCSI) so readers do not block writers and writers do not block readers. |
| OneLake Mirroring Latency |
Inspect Mirroring Monitoring tab in Fabric Workspace or query
sys.dm_change_feed_log_scan_sessions.
|
Transactional changes taking minutes to appear in Lakehouse Delta tables. |
1. Ensure primary keys exist on all mirrored tables. 2. Avoid massive unbatched single transactions; break large updates into batches of 50,000 rows. |
-- 1. Identify Top Missing Indexes with High Potential User Impact
SELECT TOP 10
ROUND(s.avg_total_user_cost * s.avg_user_impact * (s.user_seeks + s.user_scans), 0) AS improvement_score,
d.statement AS table_name,
d.equality_columns,
d.inequality_columns,
d.included_columns
FROM sys.dm_db_missing_index_group_stats s
JOIN sys.dm_db_missing_index_groups g ON s.group_handle = g.index_group_handle
JOIN sys.dm_db_missing_index_details d ON g.index_handle = d.index_handle
ORDER BY improvement_score DESC;
-- 2. Find Top 10 Most CPU-Intensive Queries in Query Store
SELECT TOP 10
q.query_id,
qt.query_sql_text,
SUM(rs.count_executions) AS total_executions,
ROUND(AVG(rs.avg_duration) / 1000.0, 2) AS avg_duration_ms,
ROUND(AVG(rs.avg_cpu_time) / 1000.0, 2) AS avg_cpu_ms
FROM sys.query_store_query q
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan p ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
GROUP BY q.query_id, qt.query_sql_text
ORDER BY avg_cpu_ms DESC;
Spark Pain-Point Step-Through Diagrams
Four failure modes account for most Spark incidents. Each one below is animated step by step so the mechanism is visible rather than described — pick a pain point, then step through what actually happens inside the executors.
Reading & Fixing Spark Query Plans
The physical plan is the engine's confession: every performance problem in this
document leaves a named operator or a telltale attribute in
EXPLAIN output. This section teaches the reading skill; the companion
spark_plan_analyzer.py utility automates it across whole notebook trees.
Getting a plan (without running anything)
| How | What you get |
|---|---|
df.explain("formatted") |
Numbered operator tree + per-operator detail blocks — the mode to learn on. |
EXPLAIN FORMATTED <sql> |
Same, as a SQL statement. Plans are compiled, never executed — safe on production tables (Delta may run small metadata jobs to read the log). |
df.explain("cost") |
Optimized logical plan with row-count/size statistics — why the optimizer chose what it chose. |
df.explain("codegen") |
The generated Java — for verifying whole-stage fusion (Sec 05). |
| SQL tab, after running |
The plan with runtime metrics and isFinalPlan=true — the
only place AQE's actual decisions are visible (Sec 06).
|
spark_plan_analyzer.review() |
Walks a notebook + its %run/notebook.run/runMultiple
dependency tree, EXPLAINs every extractable SQL, lints the code, and emits one
markdown review with all plans attached.
PlanRecorder.grab(df, name) captures DataFrame-API plans
mid-notebook.
|
Interactive: annotated plan — click any line
// Select a sample query plan above to explore operator nodes
Red flags — operator/attribute → problem → fix
| In the plan | Problem | Fix |
|---|---|---|
| CartesianProduct / BroadcastNestedLoopJoin | No usable equi-join keys — row-explosion or per-row scans of the broadcast side. | Fix the ON clause (type mismatch or UDF-wrapped key kills equi-join detection); bucketize range joins into keys. |
| BatchEvalPython | Row-at-a-time Python UDF: breaks codegen fusion, serializes every row, forces NEE fallback. | Built-ins first; else @pandas_udf (shows as ArrowEvalPython). |
| PushedFilters: [] | Predicate pushdown failed — the scan reads everything, Spark filters late. | Filter on the raw column; cast the literal, not the column; no UDFs in predicates. |
| PartitionFilters: [] | Partitioned/clustered table fully scanned. | Literal predicate on the partition/cluster column, no functions on the column side. |
| SortMergeJoin (small side) | Two sorts + two shuffles where a broadcast would do. |
broadcast(dim) hint, or confirm AQE converts it in the final plan.
|
| Exchange × many | Shuffle storm — each Exchange is a full network+disk barrier. | Reuse grain (aggregate once), Storage-Partition/bucketed joins, persist genuinely reused intermediates. |
| no AdaptiveSparkPlan wrapper | AQE off for this query — no coalescing, no join conversion, no skew handling. | spark.sql.adaptive.enabled=true and ask why it was disabled. |
| missing *(n) prefixes | Operators outside whole-stage codegen — interpreted row-by-row evaluation. | Usually a UDF or unsupported expression in the middle; remove it and fusion returns. |
| ReadSchema: struct<40 fields> | Column pruning didn't reach the scan. | Select needed columns before wide ops; avoid select("*") pass-through in shared helpers. |
review("nb_entry.ipynb", "plan_review.md", spark)
in a scheduled QA notebook — it follows the dependency tree, EXPLAINs every SQL
against the live catalog, flags L-codes (code anti-patterns: unbounded collect,
inferSchema, session-start
conf.set, except-pass…) and P-codes (plan issues above), each with a
rewrite suggestion and the related settings. Findings land in one markdown review —
attach it to PRs, or write it to a Lakehouse Files folder and alert on CRITICAL counts
like any other Sec 21 signal.
FileScan node reveals whether Delta file skipping is working — look for
numFilesSkipped > 0 in the plan's data statistics comment. If skipping
shows 0%, your filter column may be past the 32-column index limit. See
Sec 24b — Column Stats & SQL Tuning for
the full diagnostic and remediation workflow.
The Settings Matrix — Unified Baseline
This document shows %%configure blocks in several places, each tuned to
the topic at hand. That is useful in context and confusing in aggregate.
This section is the reconciliation: one baseline, plus the exact deltas each
workload class adds. If a block elsewhere in the document disagrees with this table,
this table wins.
advisoryPartitionSizeInBytes, the ANSI/NEE decision on Runtime 2.0, and
the Efficient Scaledown group for shuffle-heavy autoscaling jobs. Everything else is
either a Fabric default that is already correct, or a setting you should change only
after the plan and stage metrics have told you to (Sec 29).
Layer A — the universal baseline (set once, Environment level)
| Setting | Value | Why it is here |
|---|---|---|
| spark.sql.adaptive.enabled | true | Fabric default. Listed only so you can confirm it was not disabled by an inherited Environment. |
| spark.sql.adaptive.coalescePartitions.enabled | true | Fabric default. The half of AQE that fixes partition sizing from runtime stats. |
| spark.sql.adaptive.skewJoin.enabled | true | Fabric default. Handles join skew; aggregation skew still needs salting. |
| spark.native.enabled | true | NEE. GA on both runtimes; also settable via the Environment's Acceleration tab. |
| spark.microsoft.delta.optimizeWrite.enabled | true | Fabric default. First defence against small files. |
Do not paste this into %%configure. Every line is already the
Fabric default — restating defaults pins them, so a future platform improvement
silently does not reach you. Verify, don't set.
Layer B — workload deltas (the only blocks worth writing)
%%configure -f
{"conf": {
"spark.sql.parquet.vorder.default": "false"
}}
One line, and even that is only needed if your workspace default enables V-Order. No shuffle tuning, because there is no shuffle. First ask whether this needs Spark at all — under ~10 GB a Python notebook is a fraction of the CU (Sec 34).
%%configure -f
{"conf": {
"spark.sql.adaptive.advisoryPartitionSizeInBytes": "134217728",
"spark.remote.shuffle.enabled": "true",
"spark.sql.rsm.decisionlayer.enabled.level": "stage",
"spark.storage.decommission.shuffleBlocks.enabled": "true",
"spark.sql.parquet.vorder.default": "false"
}}
128 MB advisory targets post-shuffle partitions that fit per-core execution memory. The three Efficient Scaledown keys matter only if the pool autoscales — they let executors release without taking shuffle blocks with them (Sec 13).
%%configure -f
{"conf": {
"spark.sql.adaptive.advisoryPartitionSizeInBytes": "268435456",
"spark.sql.parquet.vorder.default": "true"
}}
256 MB because gold aggregates produce fewer, larger partitions and the output is small. V-Order on: this is the layer Direct Lake and the SQL endpoint read.
%%configure -f
{"conf": {}}
Genuinely nothing. Use the starter pool, high concurrency, and Autotune if the query shapes repeat. Tuning interactive sessions by hand usually costs more time than it saves.
// throughput-critical batch: regain native execution
"spark.sql.ansi.enabled": "false"
// correctness-critical: keep ANSI guards, accept the JVM path
"spark.sql.ansi.enabled": "true"
There is no universally right answer — it is a per-workload decision, and the one setting on Runtime 2.0 you genuinely must make consciously (Sec 08).
Reconciling the conflicts you may have spotted
| Apparent conflict | Resolution |
|---|---|
| advisory size 128 MB vs 256 MB | Both correct, different layers. 128 MB for silver (many partitions, merge-heavy, spill-sensitive); 256 MB for gold (fewer, larger partitions, scan-heavy). If unsure, start at 128 MB — under-sized partitions cost scheduling overhead, over-sized ones cost spill, and spill is the more expensive failure. |
| V-Order true vs false | Layer-dependent by design: false on bronze/silver (you pay ~10-15% write cost for reads that happen elsewhere), true on gold only. |
| ANSI true vs false | Runtime 2.0 only, and deliberately unresolved — it is a trade-off, not a default. Decide per workload and record the decision in the Environment. |
| Efficient Scaledown keys present/absent | Only include them when the pool autoscales and the job shuffles heavily. On a fixed-size pool they add nothing. |
| Memory fractions appearing in some blocks |
They should not. The Config Advisor now emits only non-default settings; any
block showing spark.memory.fraction=0.6 is restating a default and
can be deleted.
|
Interactive: generate the exact block for your workload
Recommended Baseline Defaults & Scopes
Most Spark tuning advice assumes you already have a baseline. This is that baseline: what to set once at the Environment level so individual notebooks rarely need to tune anything, plus what deliberately stays at its default. Every value is a starting point to validate, not a law — the Config Advisor and cluster console derive per-workload numbers from your actual data.
Layer 3 — set once on the Environment (applies to every notebook)
| Setting | Baseline | Why |
|---|---|---|
| spark.sql.adaptive.enabled | true | Default since 3.2. Partition coalescing, join conversion and skew splitting all depend on it. Never turn off. |
| spark.sql.adaptive.coalescePartitions.enabled | true | The half of AQE that fixes both over- and under-partitioning from runtime stats. |
| spark.sql.adaptive.advisoryPartitionSizeInBytes | 134217728 | The one number worth setting deliberately. 128 MB targets post-shuffle partitions that fit comfortably in per-core execution memory. Raise to 256 MB for very large scans; lower if you see spill. |
| spark.sql.adaptive.skewJoin.enabled | true | Splits hot join partitions automatically. Aggregation skew still needs salting. |
| spark.sql.shuffle.partitions | 200 (leave) | With AQE coalescing this is only an upper bound — tuning it is mostly obsolete. Raise only if a stage is throttled at 200 before coalescing. |
| spark.sql.autoBroadcastJoinThreshold | leave default | AQE re-evaluates broadcast eligibility from real post-filter sizes, which beats a static threshold. Raise only with measured dimension sizes and driver headroom. |
| spark.native.enabled | true |
NEE via the Environment's Acceleration tab. Verify it is actually native (*Transformer
operators), especially on Runtime 2.0 where ANSI causes fallback.
|
| spark.microsoft.delta.optimizeWrite.enabled | true | Fabric default. First defence against small files; drop bin size to 128 MB for merge-heavy tables. |
| spark.sql.parquet.vorder.default | false at Environment | Enable per-table on gold instead — a blanket default taxes every bronze/silver write for reads that happen elsewhere. |
Runtime-specific baselines
| Setting | Runtime 1.3 | Runtime 2.0 | Note |
|---|---|---|---|
| spark.sql.ansi.enabled | false (default) | decide explicitly |
On by default in 4.x. Choose true for correctness guards or
false to regain NEE offload — per workload, recorded in the
Environment, not ad hoc.
|
| Liquid clustering | preview flag | standard | Delta 3.2 requires the clustered-table preview flag; Delta 4.2 does not. |
| spark.speculation | leave off | leave off | Speculation masks skew rather than fixing it. If enabled, 4.x defaults are already less aggressive (3 / 0.9). |
| Efficient Scaledown / RSM | opt-in | opt-in | Enable for shuffle-heavy autoscaling jobs; it is what makes aggressive scale-down safe. |
Settings people change that they usually shouldn't
| Setting | Why leaving it alone is usually right |
|---|---|
| spark.memory.fraction / storageFraction | The unified model already lets execution and storage borrow from each other. Raising storage to "help caching" starves execution and causes the spill you were trying to avoid. |
| spark.sql.files.maxPartitionBytes | Read-side sizing that AQE does not touch. 128 MB matches typical block sizes; changing it usually treats a small-files symptom that OPTIMIZE should fix. |
| spark.executor.cores above ~5 | More cores per executor means less memory per task and more HDFS/object-store contention. Fabric's fixed node shapes already encode sane ratios. |
| spark.sql.crossJoin.enabled | Enabling it silences the symptom of a broken join predicate. Fix the predicate. |
| spark.driver.memory (as an OOM fix) |
Driver OOM almost always means collect()/toPandas() or
a mis-sized broadcast. More heap postpones the same failure.
|
| Hard-coded repartition(n) | Not a config, but the same instinct — it overrides exactly what AQE is measuring for you. |
Baseline by workload shape
| Shape | Starting point |
|---|---|
| Bronze ingest (scan + write) | Small pool, few nodes. No shuffle tuning needed. V-Order off, CDF on if silver reads incrementally. Check whether Spark is needed at all — under ~10 GB a Python notebook with Polars/DuckDB is a fraction of the CU. |
| Silver conform (join + MERGE) | Advisory partition size 128 MB; deletion vectors + CDF on the target; liquid clustering on join/filter keys; Efficient Scaledown on. This is the shape that most rewards tuning. |
| Gold aggregate (wide groupBy) | Advisory 128–256 MB; V-Order on the target; OPTIMIZE after load; consider a Materialized Lake View instead of orchestrating a rebuild. Size the pool from shuffle volume, not input size. |
| Interactive / ad hoc SQL | Defaults plus high concurrency; consider Autotune, which converges on repetitive query shapes. Keep sessions warm rather than tuning. |
| Streaming | Partition count sized to steady-state micro-batch, not peak. NEE will fall back — size compute on JVM performance. |
Spark SQL in Fabric — Patterns & 4.x Features
Everything the DataFrame API can express, Spark SQL can too — and it goes through the identical Catalyst pipeline (Sec 04), so the plan-reading skills from Sec 27 apply unchanged. What differs is ergonomics, the pitfalls SQL invites, and the fact that Spark 4.x adds genuinely new SQL surface.
Where to write SQL, and how to parameterize it safely
| Surface | Use when |
|---|---|
%%sql cell magic |
Exploration and readable one-shot statements. Output renders as a table. Cannot take Python variables — which is a feature: no accidental injection. |
spark.sql("…") |
Programmatic SQL, returns a DataFrame for chaining. The workhorse in metadata-driven pipelines. |
spark.table("lh.schema.tbl") |
Just reading a table — clearest intent, no SQL parse. |
| SQL analytics endpoint | T-SQL over the same Delta tables for BI/analyst consumption. Read-only — transformation logic belongs in Spark or the Warehouse, not here. |
cust = get_customer_id() # from a widget/param
df = spark.sql(f"""
SELECT * FROM orders
WHERE customer_id = {cust}
""")
df = spark.sql(
"SELECT order_id, amount FROM orders WHERE customer_id = :cust",
args={"cust": cust})
SELECT * blocks column pruning at the scan (S001).
Interactive: SQL pattern gallery
Spark 4.x SQL: what's genuinely new
These have no 3.5 equivalent — they are reasons to move to Runtime 2.0, not just syntax sugar. Each pair below shows the 3.5 workaround and the 4.x native form.
# control flow must live in Python
for region in regions:
spark.sql(f"INSERT INTO gold.summary "
f"SELECT ... WHERE region = '{region}'")
BEGIN
DECLARE region_cursor STRING;
FOR row AS SELECT DISTINCT region FROM silver.orders DO
INSERT INTO gold.summary
SELECT * FROM silver.orders WHERE region = row.region;
END FOR;
END
SELECT region, total FROM (
SELECT region, SUM(amount) AS total FROM (
SELECT * FROM orders WHERE status = 'complete'
) GROUP BY region
) WHERE total > 1000
FROM orders
|> WHERE status = 'complete'
|> AGGREGATE SUM(amount) AS total GROUP BY region
|> WHERE total > 1000
@udf("double")
def net_amount(amount, vat):
return amount / (1 + vat)
CREATE FUNCTION net_amount(amount DOUBLE, vat DOUBLE)
RETURNS DOUBLE
RETURN amount / (1 + vat);
CAST('abc' AS INT) that returned NULL on 3.5 throws on
Runtime 2.0. Wrap knowingly-dirty conversions in try_cast/try_divide
rather than disabling ANSI globally — unless you have chosen
native_speed for NEE reasons, which is a workload-level decision.
CDF Incremental Pipeline in Pure SQL
Everything here is %%sql. No DataFrame API, no PySpark. The goal: take an
MLV that Fabric refreshes fully every cycle, and reproduce its output incrementally
using Change Data Feed plus MERGE, so cost scales with change volume
rather than table size.
Step 0 — First, confirm the MLV really is full-refreshing (and why)
Do not rewrite anything until you know which of the five gating conditions is firing (Sec 24). Run these three checks:
-- (a) Is CDF on for EVERY source the MLV references? Any single miss = full refresh.
SHOW TBLPROPERTIES MarketRisk_Position_LTH_Exposures ('delta.enableChangeDataFeed');
SHOW TBLPROPERTIES MarketRisk_Position_LTH_Sensitivities ('delta.enableChangeDataFeed');
-- ...repeat for all sources, including any upstream MLVs in the chain
-- (b) Has any source seen a non-append operation since the last refresh?
-- UPDATE / DELETE / MERGE anywhere = full refresh, regardless of SQL constructs.
SELECT version, timestamp, operation
FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures)
WHERE operation IN ('UPDATE','DELETE','MERGE')
ORDER BY version DESC LIMIT 20;
-- (c) Does the definition use constructs that block incremental?
-- Window functions and non-deterministic functions are the documented blockers.
SHOW CREATE TABLE mlv.Standard_PositionLookthrough_Link;
CREATE OR REPLACE MATERIALIZED LAKE VIEW mlv.Standard_PositionLookthrough_Link
TBLPROPERTIES (delta.enableChangeDataFeed=true) AS
WITH source_rows AS (
SELECT 'exposures' AS source_name, ... FROM MarketRisk_Position_LTH_Exposures WHERE Reportlevel <= 1
UNION ALL
SELECT 'volmetrics' AS source_name, ... FROM MarketRisk_Position_LTH_VolMetricsTerms WHERE Reportlevel <= 1
UNION ALL ... -- 9 sources in total
),
ranked_rows AS (
SELECT ..., xxhash64(EffectiveDate, EntityId, ...) AS match_key,
ROW_NUMBER() OVER (PARTITION BY source_name, match_key, COALESCE(RiskSetting,'')
ORDER BY record_id DESC, record_key DESC) AS row_number
FROM source_rows
)
SELECT ... FROM exposures pos
INNER JOIN volmetrics vol ON pos.match_key = vol.match_key
LEFT JOIN barra bar ON pos.match_key = bar.match_key ...
-
ROW_NUMBER() OVER (...)— a window function, the canonical documented unsupported construct. Deduplication-by-ranking is the most common reason production MLVs never go incremental. -
Non-append source traffic — risk tables of this kind are typically reloaded
or corrected per effective date, so
UPDATE/DELETE/MERGEappear in their history. That alone forces full refresh even with CDF on and no window function. -
Chained MLVs — a downstream MLV joining
mlv.Standard_PositionLookthrough_Linkinherits the problem: if any referenced MLV or table lacks CDF, or the chain contains a full-refresh node, incremental is off for that node too. Check every node in the DAG, not just the leaf.
TBLPROPERTIES (delta.enableChangeDataFeed=true), so even
though the intermediates have it, anything reading the final view cannot go
incremental. Enable CDF on every node you intend to consume incrementally.
Step 1 — Control table for watermarks (pure SQL)
CREATE TABLE IF NOT EXISTS ctl.cdf_watermark (
entity_name STRING NOT NULL,
source_table STRING NOT NULL,
last_version BIGINT NOT NULL,
updated_at TIMESTAMP
) USING DELTA
TBLPROPERTIES (delta.enableDeletionVectors = true);
-- Seed once per entity. Version 0 = "process everything from the beginning".
INSERT INTO ctl.cdf_watermark
SELECT 'position_lookthrough', 'MarketRisk_Position_LTH_Exposures', 0, current_timestamp()
WHERE NOT EXISTS (SELECT 1 FROM ctl.cdf_watermark WHERE entity_name = 'position_lookthrough');
Step 2 — Create the target as a real Delta table, not a view
This is the table the MLV used to produce. Because you now own it, you also choose its physical properties — which an MLV does not let you tune directly:
CREATE TABLE IF NOT EXISTS gold.position_lookthrough_link (
Standard_PositionLookthrough_Link_Key BIGINT,
EffectiveDate DATE,
EntityId STRING,
BenchmarkId STRING,
Reportlevel INT,
RiskSetting STRING,
RP_PositionId_Hash_Key BIGINT,
SecurityId INT,
_merged_at TIMESTAMP
) USING DELTA
CLUSTER BY (EffectiveDate, EntityId) -- liquid clustering: GA on Runtime 2.0
TBLPROPERTIES (
delta.enableDeletionVectors = true, -- MERGE marks rows instead of rewriting files
delta.enableChangeDataFeed = true, -- so the NEXT layer can also go incremental
delta.targetFileSize = '400mb' -- gold layer target (Sec 30)
);
Step 3 — The incremental load, entirely in SQL
Two runtime flavours. The logic is identical; only how the watermark is held differs.
BEGIN
DECLARE wm BIGINT;
DECLARE tgt BIGINT;
SET VAR wm = (SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'position_lookthrough');
SET VAR tgt = (SELECT max(version)
FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures));
-- 1. Changed keys only: version-bounded, so files outside the window are never opened
CREATE OR REPLACE TEMP VIEW v_changed_keys AS
SELECT DISTINCT EffectiveDate, EntityId
FROM table_changes('MarketRisk_Position_LTH_Exposures', wm + 1, tgt)
WHERE _change_type != 'update_preimage'
AND Reportlevel <= 1;
-- 2. Recompute the FULL business logic, but only for affected grains.
-- Window functions are fine here - you are not an MLV, nothing falls back.
CREATE OR REPLACE TEMP VIEW v_recomputed AS
WITH scoped AS (
SELECT e.* FROM MarketRisk_Position_LTH_Exposures e
JOIN v_changed_keys k
ON e.EffectiveDate = k.EffectiveDate AND e.EntityId = k.EntityId
WHERE e.Reportlevel <= 1
),
ranked AS (
SELECT s.*,
xxhash64(EffectiveDate, EntityId, BenchmarkId, Reportlevel, OriginalName) AS match_key
FROM scoped s
QUALIFY ROW_NUMBER() OVER (
PARTITION BY EffectiveDate, EntityId, BenchmarkId, OriginalName
ORDER BY MarketRisk_Position_LTH_Exposures_Id DESC) = 1
)
SELECT xxhash64(match_key) AS Standard_PositionLookthrough_Link_Key,
EffectiveDate, EntityId, BenchmarkId, Reportlevel,
CAST(NULL AS STRING) AS RiskSetting,
xxhash64(RP_PositionId) AS RP_PositionId_Hash_Key,
TRY_CAST(split(RP_PositionId, '~')[1] AS INT) AS SecurityId,
current_timestamp() AS _merged_at
FROM ranked;
-- 3. Apply. Full business key in the ON clause.
MERGE INTO gold.position_lookthrough_link t
USING v_recomputed s
ON t.Standard_PositionLookthrough_Link_Key = s.Standard_PositionLookthrough_Link_Key
AND t.EffectiveDate = s.EffectiveDate -- narrows the file scan on the target
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
-- 4. Advance the watermark ONLY after the MERGE commits, forward-only
MERGE INTO ctl.cdf_watermark t
USING (SELECT 'position_lookthrough' AS entity_name, tgt AS new_version) s
ON t.entity_name = s.entity_name
WHEN MATCHED AND s.new_version > t.last_version
THEN UPDATE SET last_version = s.new_version, updated_at = current_timestamp();
END
DECLARE/SET VAR) and SQL scripting
(BEGIN…END) are GA in Spark 4.1, so the whole unit is one reviewable
artifact with no host language.
-- No session variables on 3.5. Hold the bounds in a one-row temp view
-- and cross-join it, so the SQL still parameterises itself with no Python.
CREATE OR REPLACE TEMP VIEW v_bounds AS
SELECT
(SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'position_lookthrough') AS wm,
(SELECT max(version)
FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures)) AS tgt;
-- table_changes() needs LITERAL version arguments - it cannot read them
-- from a view. On 3.5 you therefore either:
-- (a) pass them in from the orchestrator as pipeline parameters, or
-- (b) accept one line of host-language glue (spark.sql(..., args={}))
-- Everything downstream of the change read stays pure SQL:
CREATE OR REPLACE TEMP VIEW v_changed_keys AS
SELECT DISTINCT EffectiveDate, EntityId
FROM table_changes('MarketRisk_Position_LTH_Exposures', ${wm_plus_1}, ${tgt})
WHERE _change_type != 'update_preimage' AND Reportlevel <= 1;
table_changes() requires
literal version arguments, and 3.5 has no SQL variable to supply them. Use
pipeline parameters (${...} substitution at the notebook/pipeline
layer) or one spark.sql(..., args={}) call. The recompute, MERGE and
watermark update are identical to the 4.x version.
Step 4 — Handling deletes properly
The recompute above propagates inserts and updates. Deletes need an explicit branch, because a deleted source row simply stops appearing in the recomputed set:
-- Rows deleted from the source since the watermark
CREATE OR REPLACE TEMP VIEW v_deleted_keys AS
SELECT DISTINCT xxhash64(EffectiveDate, EntityId, BenchmarkId, Reportlevel, OriginalName) AS match_key
FROM table_changes('MarketRisk_Position_LTH_Exposures', ${wm_plus_1}, ${tgt})
WHERE _change_type = 'delete';
-- Apply as a second MERGE branch (deletion vectors make this cheap)
MERGE INTO gold.position_lookthrough_link t
USING v_deleted_keys s
ON t.Standard_PositionLookthrough_Link_Key = xxhash64(s.match_key)
WHEN MATCHED THEN DELETE;
If a key can be deleted and re-inserted within the same window, order matters: apply
deletes before the upsert MERGE, or filter deleted keys that also appear in
v_recomputed. This ordering problem is precisely what MLV's append-only
requirement exists to avoid — owning it is the cost of owning the incremental path.
Step 5 — The retention guard, in SQL
-- If the watermark predates the oldest retained version, CDF history has aged out
-- (VACUUM retention). Fail loudly and full-refresh rather than silently skipping data.
SELECT
CASE WHEN (SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'position_lookthrough')
< (SELECT min(version) FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures))
THEN raise_error('CDF history aged out - full refresh required')
ELSE 'ok' END AS retention_check;
Coding Standards — Interactive Gallery
The full catalogue from fabric_coding_standards.md, browsable here with
the analyzer codes that detect each anti-pattern. Every example is tagged
3.5+ (both runtimes), 4.x only,
or differs (shown as a version pair).
Full document with additional context:
open the rendered standards ·
automated enforcement:
spark_plan_analyzer.review(entry, out, spark, runtime="fabric-2.0").
Delta Table Maintenance Runbook
Maintenance sits below the transformation layer: it applies to the tables themselves, regardless of what wrote them. Spark notebooks, dbt-fabric, Dataflow Gen2, Copy activity and the Python kernel all produce Delta tables with identical maintenance needs — and Python-kernel tables are typically in worse shape by default, because no auto-compaction fires and VACUUM never runs automatically. Fabric Warehouse is the exception; it manages its own storage.
Layer targets — explicit, not implicit
| Layer | Target file size | Why this number |
|---|---|---|
| Bronze | ~128 MB | A pragmatic default rather than a hard requirement — bronze is read by Spark, not Direct Lake or the SQL endpoint. The priority is preventing small-file accumulation, not hitting an exact size. |
| Silver | ~256 MB | Balances Spark processing efficiency for transformation workloads — this is where correctness of the target starts to matter. |
| Gold | ~400 MB | The critical one. The SQL analytics endpoint and Power BI Direct Lake have genuine performance dependencies on file size. |
Note the distinction that trips people up: a maintenance target is the
threshold for deciding whether to call OPTIMIZE — it is not the output file
size. Adaptive Target File Size controls the actual output when OPTIMIZE runs,
adapting downward for small tables; delta.targetFileSize as a table
property gives it a per-table ceiling.
Interactive: Maintenance Cadence Advisor
Describe your table's write patterns. Get a recommended maintenance schedule with specific OPTIMIZE, DV purge, and VACUUM cadences — and the health-check query to verify conditions before each run.
Principles worth stealing
-
Maintenance should cost nothing when tables are healthy. Gate every OPTIMIZE
on a metadata check (
DESCRIBE DETAIL— no data scan, runs in seconds) and skip tables already within tolerance of their layer target. This is exactly what the health audit notebook does before the maintenance notebook acts. - The 7-day VACUUM floor is non-negotiable. Enforce it in code, not as a documentation note somebody skims — retention below 168 hours risks breaking readers mid-query and destroys your time-travel/CDF window (the CDF retention trap).
- Direct Lake coordination matters. For gold tables serving Direct Lake, VACUUM must run after the semantic model has re-framed to the latest commit — otherwise you can pull files out from under it.
- Table properties beat session configs for shared tables. A session config applies only to the current notebook session; a table property is set once and applies no matter which pipeline, engine or tool writes next. For anything written by more than one process, put it on the table.
-
Rebaseline once, then maintain. A neglected Lakehouse needs
REORG TABLE … APPLY (PURGE)plus OPTIMIZE to rewrite files to target and purge accumulated deletion vectors — a one-off, expensive pass. Afterwards, incremental maintenance is cheap. - Log every decision. No silent skips, no silent failures — a maintenance run that reports "nothing to do" must be distinguishable from one that errored quietly.
nb_lakehouse_health_audit and
nb_lakehouse_maintenance cover similar ground with a metadata-driven,
dry-run-first bias; delta-doctor goes further on layer targets and orchestration.
Evaluate both rather than writing a third from scratch.
Efficient Code & Engine Choice (Spark vs Python)
The most expensive Spark mistake in Fabric isn't a bad config — it's using a multi-node Spark session for work a single small node could do. Fabric Python notebooks run on a single node (default 2 vCores / 16 GB) at a fraction of the CU cost of even the smallest Spark session, and modern single-node engines (Polars, DuckDB, delta-rs) read and write the same OneLake Delta tables.
| Working data size | Recommended engine |
|---|---|
| < ~5–10 GB |
Python notebook + Polars or DuckDB (reading Delta via
scan_delta / delta_scan, writing via delta-rs). Single
node, seconds to start, minimal CU.
|
| ~10 – 100 GB | Small Spark session — single-node or 2–3 Medium nodes; NEE on; still fits comfortably without wide-cluster shuffles. |
| > ~100 GB, joins/shuffles | Scaled Spark pool sized by the Config Advisor (Sec 15) + Efficient Scaledown (Sec 13) so autoscale releases nodes fast. |
Two caveats that override size: (1) writes destined for gold/Direct Lake should go through Spark, because delta-rs cannot apply V-Order; (2) anything needing a genuinely distributed shuffle (large joins, global sorts) is Spark territory regardless of input size.
PySpark efficiency checklist
- Built-in functions over UDFs, always. Catalyst can't optimize through a Python UDF (Sec 04), and each row crosses the JVM↔Python boundary. When a UDF is unavoidable, use pandas/Arrow UDFs — and note NEE now supports offloading them (Sec 07).
-
Never
collect()/toPandas()unbounded data to the driver — the #1 driver-OOM cause (Sec 21). Aggregate first; sample deliberately withlimit(). -
Explicit schemas everywhere.
inferSchemaon CSV/JSON triggers an extra full scan and produces drift-prone types. Define schemas in the metadata layer (Sec 37), not inline. -
Cache with discipline:
cache()only DataFrames reused 2+ times, andunpersist()when done — cached data competes with execution memory (Sec 09) and is a spill accelerant. - Broadcast deliberately (Sec 12): hint known-small dimensions; never force-broadcast something whose size you haven't measured.
-
Partition-count hygiene (Sec 10):
coalesce()to shrink without shuffle,repartition()only when you need redistribution; let AQE coalescing do routine right-sizing. - MERGE hygiene on Delta: always a full-key ON clause; pre-filter the source to the affected key range; deletion vectors on for the target (Sec 24).
- Debuggability by construction: pure functions per transform step (unit-testable off-cluster), one logical step per notebook cell, row-count/expectation assertions between medallion hops, and structured (JSON) log lines so the diagnostic emitter (Sec 21) can index them.
polars.scan_delta() and DuckDB's delta_scan() read Lakehouse
tables directly (local path /lakehouse/default/Tables/… when a default
Lakehouse is attached, or an abfss:// URI). Both are lazy/vectorized —
push filters and column selection into the scan and only materialize the result. The
companion notebook ships tested, working examples of exactly this pattern plus the
delta-rs write path with its V-Order caveat.
Ingestion Patterns — Rate-Limited APIs & Archives
Two ingestion shapes that Spark handles badly and teams keep giving to Spark anyway. Both are network-bound single-node work: a rate-limited crawl spends its life asleep honouring the source's limit, and a zip download is one stream to one machine. This section covers where they land in the engine-choice model (Sec 34), how they register against the existing metadata schema (Sec 36), and why NEE has nothing to offer until the data is already Delta (Sec 08).
sleep().
Distributing a rate-limited crawl does not make it faster; the limit is the source's,
not yours.
Where these land in the engine decision
| Shape | Engine | Why |
|---|---|---|
| Rate-limited API (JSON) | Python notebook | Latency-bound, not compute-bound. Single node, cheap, and the crawl state (cursor) is trivially resumable. Use Copy activity instead when the endpoint is simple REST-to-file with no bespoke pagination, backoff or transformation. |
| Zipped JSON over HTTPS | Python notebook | One stream, one machine. Spark cannot parallelize a single compressed archive — a zip is not splittable, so a 20-node cluster reads it on one executor while 19 idle. |
| The transform afterwards | Spark (or Python if small) | Once landed as many gzipped NDJSON files, the read is parallelizable and the size thresholds in Sec 34 apply normally. |
Landing layout: datetime partitions, splittable files
Land into raw/{entity}/ingest_date=YYYY-MM-DD/ingest_hour=HH/ and write
gzipped NDJSON — one record per line. Three consequences worth being explicit
about:
- Replays are idempotent. Re-running a window overwrites its partition rather than appending duplicates — no dedup pass required downstream.
- Bronze loads prune. A partitioned landing zone lets the bronze reader touch only new windows instead of listing the whole raw area — the small-files listing cost described in Sec 10 is what you are avoiding.
- NDJSON is line-splittable; a single JSON document is not. One 2 GB JSON array is read by exactly one task. Many gzipped NDJSON parts are read in parallel (gzip blocks each file, but files parallelize).
Rate limiting that survives contact with a real API
for page in pages:
r = requests.get(url) # no limiter
time.sleep(0.2) # guessed
if r.status_code == 429:
time.sleep(60) # ignores Retry-After
continue # infinite loop risk
Retry-After when the server has told you the answer is
how crawls get blocked. No retry ceiling means a persistent 500 loops forever.
bucket.take() # blocks until a token frees
try:
return json.loads(urlopen(req, timeout=t).read())
except HTTPError as e:
retryable = e.code == 429 or 500 <= e.code < 600
if not retryable or attempt > max_retries:
raise # 4xx = your bug; don't burn quota
wait = e.headers.get("Retry-After")
time.sleep(float(wait) if wait else min(2 ** attempt, 60))
etl_watermark inside the page loop — it is one small write against
metadata versus re-issuing hundreds of rate-limited requests.
Metadata: one new table, keyed to the existing schema
etl_api_config extends Sec 36's
schema on entity_id and holds only what JSON columns express badly:
base_url, auth_secret_name (a Key Vault secret name),
pagination shape
(page_style/page_param/records_path/cursor_path),
requests_per_second, burst, max_retries,
timeout_seconds and landing_pattern. Everything else —
target table, layer, load type, engine hint — stays in etl_entity. Adding
an API becomes two INSERTs.
NEE scope for ingestion — where acceleration starts
| Stage | NEE benefit | Why |
|---|---|---|
| API crawl / zip download | None — no Spark | Python notebook; there is no plan to accelerate. |
| Raw JSON/NDJSON read in Spark | None — Tier 1 fallback | JSON is not a native source (Sec 08). The read falls back to JVM regardless of NEE being enabled. |
| Parse → write Delta (bronze) | Partial | The JSON parse falls back; the write path can use native Delta write acceleration. Expect conversion boundaries here. |
| Bronze → silver → gold | Full | Delta/Parquet in, built-ins throughout, flat columns — the shape NEE was built for. |
Design consequence: parse JSON once at the bronze boundary and never again. Every downstream job that re-parses raw JSON re-enters Tier 1 fallback and forfeits acceleration for that whole operator.
Interactive: ingestion planner
nb_api_zip_ingestion.ipynb runs both patterns
against a real local HTTP server — token-bucket limiting, a deliberate 429 with
Retry-After honoured, cursor persisted per page, datetime-partitioned
gzipped NDJSON output, streamed zip extraction member-by-member, and CU-estimate
logging. Executed end to end, zero errors.
Metadata Store on Fabric SQL Database
Sec 37 established why metadata beats
hard-coding. This section is the how, on the real store: a
Fabric SQL Database item — transactional, cheap at metadata scale, queryable
from notebooks and pipelines, and version-controllable via its DDL. The working
prototype is nb_metadata_sqldb_prototype.ipynb, which runs against a
local equivalent so every statement is proven, then switches to Fabric SQL DB by
parameter.
The schema
| Table | Purpose |
|---|---|
| etl_source | Systems: source_id, kind (file/api/db), connection name (secret stays in Key Vault), base path/endpoint, default format, enabled. |
| etl_entity | Per-table config: entity_id, source_id, target lakehouse/table, layer, load_type, merge_keys, watermark_column, cluster_by, table_properties (JSON), engine_hint, pool_hint, enabled. |
| etl_watermark | Current high-water mark per entity, upserted transactionally at the end of a successful run. Separated from the run log so the hot read is a single row. |
| etl_run_log | Audit: run_id, entity_id, timings, rows, status, error, spark_application_id (the jump-off into Sec 21). |
| dq_rules |
Declarative quality rules per entity with severity — consumed by the generic
runner in nb_data_quality.
|
| vw_active_entities | View joining source+entity with the enabled filters applied, so notebooks never embed filter logic. |
Connecting from a notebook — two paths, different jobs
# Obtain Entra ID bearer token for Azure SQL / Fabric SQL Database
token = notebookutils.credentials.getToken("https://database.windows.net/")
jdbc_url = f"jdbc:sqlserver://{SQL_SERVER}:1433;database={SQL_DB};encrypt=true;"
# Read a VIEW: Push filter predicates into SQL engine directly
meta = (
spark.read.format("jdbc")
.option("url", jdbc_url)
.option("accessToken", token)
.option("query", "SELECT entity_id, target_table, load_type FROM dbo.vw_active_entities WHERE layer = 'silver'")
.load()
)
# Parallel read of a LARGE table with partition pushdown
big = (
spark.read.format("jdbc")
.option("url", jdbc_url)
.option("accessToken", token)
.option("dbtable", "dbo.fact_positions")
.option("partitionColumn", "position_id")
.option("lowerBound", 1)
.option("upperBound", 50_000_000)
.option("numPartitions", 16) # 16 concurrent JDBC connections
.load()
)
executemany for batches, real transactions.
meta = (spark.read.format("jdbc")
.option("url", jdbc_url)
.option("query", "SELECT * FROM dbo.vw_active_entities")
.option("accessToken", tok_str)
.load())
Gotchas — the list that saves days
| Gotcha | What happens / what to do |
|---|---|
| Token struct packing |
The Entra token must be UTF-16-LE encoded and length-prefixed into
attrs_before={1256: …}. Get this subtly wrong and you get an opaque
login failure, not a helpful error.
|
| ODBC driver presence |
Driver name/version varies by runtime image. Probe
pyodbc.drivers() and select rather than hard-coding "ODBC Driver
18" — this is a classic Runtime 1.3 → 2.0 breakage.
|
| Writing logs via Spark JDBC |
A DataFrame write per run-log row is an enormous per-row overhead and can
deadlock under concurrency. Batch with pyodbc executemany, or
accumulate and write once per run.
|
| Metadata as a runtime dependency | Querying config inside a per-partition function turns the SQL DB into a bottleneck and can exhaust connections. Fetch once per run, cache to a dict, broadcast if needed. |
| Connections across runMultiple |
Notebooks in a shared session must not share a connection object across
concurrent activities. Open per activity, close in finally.
|
| Watermark upsert races |
Two runs of one entity can interleave. Use a single MERGE (or
UPDATE … WHERE new > current) so the watermark only moves
forward — never last-writer-wins.
|
| Non-idempotent run ids | Derive run_id from a deterministic key (entity + scheduled slot) if you need retries to update rather than duplicate rows. |
| String-concatenated SQL |
Injection plus plan-cache churn. Parameterize (? in pyodbc) — as
much a performance rule as a security one.
|
| Mirroring latency | If you read the SQL DB's mirrored OneLake tables from Spark, remember it is near-real-time, not synchronous. Control-plane reads (what should I run now?) go direct via pyodbc; analytical reads over history can use the mirror. |
| Secrets in metadata | Store connection names, resolve values from Key Vault at run time. A metadata row is not a secret store. |
| Schema drift in JSON columns |
table_properties as JSON is flexible but unvalidated — validate on
read and fail loudly rather than silently ignoring a typo'd property.
|
vw_active_entities query at run
start → config cached in a dict → per-entity work driven from memory → run-log rows
accumulated in a list → one batched executemany write + one watermark
MERGE per entity at the end. The SQL DB is touched twice per run, not
once per row.
Metadata-Driven Orchestration Frameworks
Hard-coded paths, schemas, and per-table notebooks don't survive contact with the 40th source system. The alternative: a small relational metadata store that describes every source and target, and a small set of generic notebooks that read it.
The metadata store
Fabric SQL Database is the natural home — transactional, cheap at this scale, queryable from notebooks (via connection string + Entra auth) and from pipelines alike. A workable minimal schema:
| Table | Purpose (key columns) |
|---|---|
| etl_source | Source registry: source_id, kind (file/api/db), connection ref (name only — secrets stay in Key Vault), landing path pattern, format, schema JSON, enabled flag. |
| etl_entity | Per-table config: entity_id, source_id, target lakehouse/table, layer, load type (full/incremental/CDF), merge keys, watermark column, cluster-by columns, table properties (DV/CDF/V-Order), engine hint (python/spark), pool size hint. |
| etl_run_log | Run audit: run_id, entity_id, start/end, rows read/written, watermark advanced to, status, error, Spark application id (links straight to Sec 21's Monitoring hub). |
One generic ingestion notebook then loops the enabled entities: resolve config → choose engine (Sec 34) → load → validate → merge → write run log. Adding source #41 becomes an INSERT, not a new notebook. The companion notebook demonstrates the full loop with a working metadata table.
Orchestration options — and when a pipeline isn't the right tool
| Option | Use when |
|---|---|
| notebookutils.notebook.runMultiple() | A DAG of related notebooks that should share one Spark session — you define dependencies as a DAG object and Fabric runs them with concurrency inside the session. Massive CU saving vs a pipeline invoking N notebooks that each spin up their own session; the natural engine for the metadata-driven loop above. |
| Data pipeline | Cross-item orchestration (copy activities + notebooks + warehouse procs), external triggers, retry/alerting policies, parameter passing between heterogeneous activities. Best as the outer scheduler invoking one coordinator notebook, not as a per-table fan-out. |
| Spark Job Definition | Production batch jobs as versioned artifacts (py files / JARs) with their own schedule and retry — closer to spark-submit than to a notebook; good for hardened, non-interactive workloads. |
| Apache Airflow job (Fabric-hosted) | Complex cross-platform DAGs, existing Airflow estate, or dependency logic beyond what pipelines express. |
| Activator (event-driven) | Trigger on data/file arrival or capacity/job events rather than a clock — e.g. run ingestion when the source drops a file, alert on Sec 19/Sec 21 signals. |
| Materialized Lake Views | Where a silver→gold transform is expressible as SQL, let Fabric own refresh + lineage instead of orchestrating it at all (Sec 24). |
runMultiple DAG on one right-sized Spark session (Sec 15/Sec 19) → every run writes etl_run_log with
its Spark application id → Sec 21's
diagnostic emitter and the Capacity Metrics App close the observability loop. No
hard-coded paths, one session's worth of CU, and every failure traceable in two
clicks.
Running in Fabric — Sessions, Wheels & Imports
Code that works locally often needs no change to run in Fabric — but three things do differ, and each one bites teams that assume otherwise.
1. You do not create a Spark session
Fabric starts the session for you (via Livy,
Sec 21) before your first cell runs.
spark, sc and notebookutils are already bound.
Calling SparkSession.builder…getOrCreate() is at best a no-op and at
worst misleading, because master(), Delta wiring and executor shape are
all decided by the Environment and pool — not by your code.
spark = (SparkSession.builder
.appName("my_job")
.master("local[4]") # ignored/meaningless in Fabric
.config("spark.executor.memory","8g") # too late: session exists
.getOrCreate())
try:
spark # provided by Fabric
IN_FABRIC = True
except NameError: # local/dev only
IN_FABRIC = False
from pyspark.sql import SparkSession
from delta import configure_spark_with_delta_pip
spark = configure_spark_with_delta_pip(
SparkSession.builder.master("local[4]")).getOrCreate()
%%configure -f cell above this,
or in the Environment's Spark properties. Only runtime-mutable keys can be set
from code (Sec 14).
2. Importing your own modules is not sys.path
| Route | When to use it |
|---|---|
| Environment custom library |
The production answer. Upload a .whl (Python also accepts
.py) to the Environment and Publish; every notebook attached to it
can import. Remember the re-publish requirement when moving to Runtime 2.0
(Python 3.11 → 3.13).
|
| Notebook Resources ("builtin") |
Upload the .py to the notebook's Resources folder and
from builtin import my_module. Stays attached to the notebook; good
for notebook-specific helpers. Manual and notebook-scoped — not affected by
Environment publishing.
|
| Lakehouse Files + sys.path |
sys.path.append("/lakehouse/default/Files/code") after uploading
there. Works, but the path is a runtime dependency and easy to break across
workspaces.
|
| %run another notebook |
For helpers defined in notebooks rather than .py modules.
Note it executes the notebook in your session rather than importing a module —
and behaviour differs from Jupyter's %run.
|
Inline %pip install |
Session-scoped and manual — fine for interactive exploration, wrong for production paths (slow session starts, unauditable versions). |
Python (non-Spark) notebooks are the constrained case: the practical route
today is a wheel installed inline, e.g.
%pip install /lakehouse/default/Files/code/toolkit-0.1-py3-none-any.whl.
3. Choosing the notebook type
| Item type | Use when |
|---|---|
| Spark notebook (PySpark) |
Distributed shuffles, large joins, V-Order writes, Delta DDL
(OPTIMIZE, VACUUM, ALTER TABLE all
require Spark). The default for the kit's notebooks.
|
| Python notebook | Single node, no Spark session at all — you do not create one, and there is nothing to size. Right for <~10 GB work with Polars/DuckDB/delta-rs, API calls, and orchestration glue (Sec 34). Note the maintenance consequence: tables written this way get no auto-compaction, so they need maintenance more, not less (Sec 33). |
| Spark Job Definition | Hardened non-interactive batch as a versioned artifact — closer to spark-submit than to a notebook. |
Runtime 2.0 Deep Dive — Spark 4.1 & Delta 4.2
Status: Runtime 2.0 is Generally Available on Apache Spark 4.1 and Delta Lake 4.2, with Python 3.13, Java 21, Scala 2.13 and Azure Linux 3.0. It is production-ready — but it is not yet the default runtime, so you select it explicitly at workspace or Environment level. Sec 17 lists the changes; this section shows what they do to real code.
1. ANSI mode — the change that breaks working code
SELECT CAST('abc' AS INT) AS a, -- NULL
CAST('9999999999' AS INT) AS b, -- NULL (overflow wraps to NULL)
10 / 0 AS c, -- NULL
element_at(array(1,2,3), 9) AS d; -- NULL
-- Same statements now THROW:
-- CAST_INVALID_INPUT, ARITHMETIC_OVERFLOW, DIVIDE_BY_ZERO,
-- INVALID_ARRAY_INDEX
-- Version-portable rewrite - works identically on BOTH runtimes:
SELECT try_cast('abc' AS INT) AS a,
try_cast('9999999999' AS INT) AS b,
try_divide(10, 0) AS c,
try_element_at(array(1,2,3), 9) AS d;
spark.sql.ansi.enabled=true on
Runtime 1.3 and run your suite. Every failure is a bug Runtime 2.0 would
surface — found before you migrate, on a runtime you can roll back instantly.
2. SQL scripting — control flow without a host language
-- 4.1 only. Loop over entities and load each, entirely in SQL.
BEGIN
DECLARE done BOOLEAN DEFAULT false;
DECLARE rows_loaded BIGINT DEFAULT 0;
FOR entity AS (SELECT entity_name, target_table FROM ctl.etl_entity WHERE enabled) DO
BEGIN
-- per-entity work here
SET VAR rows_loaded = rows_loaded + 1;
EXCEPTION WHEN OTHERS THEN
INSERT INTO ctl.etl_error VALUES (entity.entity_name, current_timestamp());
END;
END FOR;
INSERT INTO ctl.etl_run_log VALUES ('batch', rows_loaded, current_timestamp());
END
Exception handling and loops inside SQL. On 1.3 this logic lives in Python around the SQL, split across two languages.
3. VARIANT — semi-structured without the JSON-string tax
-- Runtime 1.3: Parse JSON on every read (falls back from NEE)
SELECT
get_json_object(payload, '$.device.id') AS device_id,
get_json_object(payload, '$.reading') AS reading
FROM bronze.telemetry;
-- 1. Create table with native VARIANT column (Delta 4.x / Spark 4.1)
CREATE TABLE bronze.telemetry (
event_id BIGINT,
payload VARIANT
) USING DELTA;
-- 2. Ingest raw JSON payload directly into binary shredded VARIANT
INSERT INTO bronze.telemetry
SELECT
event_id,
parse_json(raw_payload)
FROM landing.raw;
-- 3. Query semi-structured elements with zero JSON parsing overhead
SELECT
payload:device.id::STRING AS device_id,
payload:reading::DOUBLE AS reading
FROM bronze.telemetry;
4. PIPE syntax — readability as a performance feature
-- 4.1 only. Identical plan to the nested-subquery form; far easier to review.
FROM silver.orders
|> WHERE status = 'complete'
|> AGGREGATE SUM(amount) AS revenue GROUP BY order_date, region
|> WHERE revenue > 10000
|> ORDER BY revenue DESC
|> LIMIT 100;
5. SQL UDFs — shared logic that Catalyst can see through
@udf("double")
def net_of_vat(amount, vat):
return amount / (1 + vat)
CREATE FUNCTION net_of_vat(amount DOUBLE, vat DOUBLE)
RETURNS DOUBLE
RETURN amount / (1 + vat);
SELECT net_of_vat(gross, 0.20) FROM silver.invoices;
6. Delta Lake 4.2 and NEE coverage
- Liquid clustering is standard — no preview flag, unlike Delta 3.2 on Runtime 1.3.
- NEE now accelerates Python UDFs, Scala UDFs and complex types (arrays, maps, structs), plus vectorized CSV — materially wider coverage than at 1.3. Built-ins still beat UDFs, but the penalty is smaller.
- Delta 4.x-only features (VARIANT columns, collations) remain Spark-only. Enabling them on a table read by the SQL endpoint, Direct Lake or another engine breaks that reader. Gate behind interop review.
7. The migration checklist that actually bites
| Item | What happens if you skip it |
|---|---|
| Re-publish Environments with libraries | Python 3.11 → 3.13 means every custom/public library must be re-added and Published. Skip it and jobs fail with "No module found" / "Class not found". Export your library list first. |
| Recompile Scala/Java JARs | Scala 2.12 → 2.13, JDK 11 → 21. Old JARs fail to load. |
| ANSI audit | Silent-NULL logic starts throwing in production. Pre-test on 1.3 as shown above. |
| Verify NEE is actually native |
ANSI-on defaults mean an "NEE enabled" 2.0 session can run entirely on the JVM.
Check for *Transformer operators (Sec 08).
|
| Deprecated paths | WASB → ABFS; EventHubConnector → Kafka connector; SparkR → sparklyr/PySpark. |
| Rollback plan | Runtime is an Environment-level setting, so reverting to 1.3 is a config change, not a rebuild. Keep the 1.3 Environment intact until you are confident. |
Runtime Migration Decision Framework
Sec 17 lists what changed; this decides whether your workload should move. Runtime 2.0 is now GA, so the question is no longer is it safe but is your code ready.
| Workload | What 2.0 gains | Verdict |
|---|---|---|
| Batch ETL (Delta, built-ins) | Delta 4.2, liquid clustering ungated, native write acceleration, Spark 4.x perf | Pilot now. Lowest-risk class — but run the ANSI pre-test first. |
| Interactive / ad hoc SQL | PIPE syntax, SQL scripting, SQL UDFs, session variables | Pilot now. Biggest ergonomic win; low blast radius if it misbehaves. |
| Streaming | transformWithState, State Data Source, Real-Time Mode (4.1) | Pilot in isolation. Real gains, but streaming still falls back from NEE and state migration needs care. |
| ML / feature engineering | Spark ML on Connect, Python 3.13, newer pandas/PyArrow | Move deliberately. Library re-publish churn is highest here (Python 3.13); pin and test thoroughly. |
| BI-serving gold (Direct Lake) | Delta 4.2 write path | Move last. GA removes the preview risk, but Delta 4.x-only features still risk interop with non-Spark readers, and the upside is smallest here. |
Interactive: migration readiness scorer
Eight questions. Scores your specific blockers rather than giving a generic verdict.
Runbook
-
Pre-flight on 1.3: set
spark.sql.ansi.enabled=truein a dev Environment and run your suite. This surfaces the dominant 4.x breakage before you change runtimes. Automate withreview(..., runtime="fabric-2.0")to flag ANSI-sensitive code and NEE interactions. - Export Environment library lists for every Environment, then re-add and Publish after switching (mandatory — Python 3.11 → 3.13).
- Recompile custom Scala/Java JARs for Scala 2.13 / JDK 21.
- Sweep deprecated paths: WASB → ABFS; EventHubConnector → Kafka connector; SparkR → sparklyr/PySpark.
- Gate Delta 4.x-only features (collations, VARIANT columns) on tables read by other Fabric engines.
- Decide ANSI × NEE per workload and record it in the Environment's Spark properties, not ad hoc in notebooks.
-
Verify NEE is actually native post-migration (N003/N007, Advisor alerts,
nb_nee_fallback_analyzer) — an ANSI-on 2.0 environment can silently run JVM-only. - Rollback plan: keep the 1.3 Environment intact and switchable; runtime is an Environment-level setting, so reverting is a config change, not a rebuild.
Environment Setup Runbook
Everything in this document is settings-and-patterns; this section is the order to apply them in on an existing workspace, without a rebuild. Work top-down: each step depends on the one above, and steps 1–4 are one-time while 5–8 are per-pipeline habits.
nb_lakehouse_health_audit and open
the Capacity Metrics App. Capture the current state — file counts, average file sizes,
CU consumption by item — so every change afterwards has a before/after. Tuning without
a baseline is guessing with extra steps.
Step 1 — Capacity & workspace (admin, one-time)
- Confirm the SKU and burst posture. Note CU → vCores (×2) and the 3× burst ceiling (Sec 19). If a single large job needs the ceiling, Job bursting must be on and the pool's Autoscale max set high enough.
- Turn on surge protection with a background-rejection threshold, so runaway background work is refused before it pushes the capacity into the interactive-rejection band that hurts users.
- Decide Autoscale Billing for Spark if Spark contends with Power BI on a shared capacity — it moves Spark to serverless per-job billing, off the capacity entirely.
- Separate dev from prod capacity where budget allows; a paused small dev capacity stops ad-hoc notebooks competing with production.
Step 2 — Pools (workspace Spark settings → Pool tab)
- Starter pool for interactive/dev: Medium nodes, seconds to start. Leave it alone.
- Custom pool for production batch: size from the Config Advisor, set minimum nodes low (admission is optimistic — a low minimum admits far more often) and Autoscale max at what the job genuinely needs.
- Leave "Customize compute configurations for items" ON if you want notebooks to be able to override; off means the workspace pool wins everywhere.
- High concurrency ON — session sharing across notebooks is a large CU saving for interactive work.
Step 3 — Environment items (the real control plane)
- Create one Environment per medallion layer or per workload class, not one per notebook. This is where runtime version, Spark properties, libraries and the NEE toggle live (Sec 38).
- Set the runtime version deliberately — 1.3 for production, 2.0 only in a pilot Environment (Sec 40).
- Apply the baseline Spark properties from Sec 29. Remember: only settings that differ from defaults belong here.
- Acceleration tab → NEE on, then verify it is actually native (Sec 08) rather than assuming.
-
Upload utility modules as an Environment custom library and Publish — this is
what makes
import fabric_workload_advisorwork everywhere.
Step 4 — Metadata & table properties (one-time, then self-sustaining)
-
Deploy the Fabric SQL Database schema (Sec 36) and register your existing sources into
etl_source/etl_entity. This is the highest-leverage single change: it turns N notebooks into one. - Run a one-off property sweep: deletion vectors + CDF on merge-heavy silver, V-Order on gold only, clustering on tables ≥10 GB (Sec 24).
-
If tables have never been maintained, do a rebaseline —
REORG … APPLY (PURGE)then OPTIMIZE — before switching to incremental maintenance (Sec 33).
Steps 5–8 — the habits that keep it healthy
| Step | Habit | Why |
|---|---|---|
| 5 | Session config at the top of every notebook |
A %%configure cell or Environment attachment — never
spark.conf.set for session-start keys (Sec 14).
|
| 6 | Run-log write at the end of every run | Entity, engine, duration, CU estimate, Spark app id (Sec 20) — this is your cross-workspace cost rollup and your incident jump-off. |
| 7 | Maintenance as the last pipeline activity | Health-gated OPTIMIZE per table, VACUUM on a schedule respecting the 7-day floor (Sec 33). |
| 8 | Weekly review of audit + capacity | Health report trend, throttling events, CU per entity. Fifteen minutes weekly prevents the quarterly emergency. |
Interactive: generate your config pack
Beyond Spark — OneLake Security, CI/CD & Governance
This document is deliberately Spark-and-Lakehouse deep. But an expert Fabric data engineer is judged on four adjacent areas too, and neglecting them is what separates a good notebook author from someone who can own a platform. This section is a map with enough depth to act on, not a substitute for the docs — statuses move, so verify anything marked preview.
1. Security & governance
| Layer | What it does / when to use it |
|---|---|
| Workspace roles | Admin / Member / Contributor / Viewer — the coarse grant. Most over-permissioning starts here: Contributor on a production workspace is write access to everything in it. |
| Item permissions | Per-item sharing for cases where workspace-level is too broad. |
| OneLake security (RLS/CLS) | Centralizes access control at the data layer so rules follow the data across engines rather than being re-implemented per experience. GA'd April 2026. Applied from the Lakehouse/mirrored item via "Manage OneLake security" roles. |
| Semantic model RLS | Report-scoped rules. Layers stack — a user must satisfy the effective rules on the whole path, and the semantic model must not be used to grant back what the data layer denied. |
| Workspace Identity | A managed identity for the workspace, letting pipelines/notebooks/shortcuts reach secured sources without stored credentials, and enabling trusted workspace access from storage accounts. The correct answer to "where do we put the service principal secret". |
| Sensitivity labels & Govern tab | Purview-based labelling; as of January 2026 the governance insights moved from the Purview Hub into the Govern tab of the OneLake Catalog — label coverage, endorsement, security health in one place. |
| Encryption | Microsoft-managed keys by default; customer-managed keys via Key Vault where required. |
Known sharp edge: combining shortcuts, SQL-endpoint RLS and OneLake security across workspaces is not fully supported — the practical pattern is to materialize gold physically rather than shortcut it, or centralize RLS in the semantic layer.
2. CI/CD and environment promotion
- Git integration stores item definitions — notebook code, pipeline JSON, configuration. Data is not version controlled: Delta tables, warehouse data and OneLake files stay in OneLake. Treat your metadata DDL as code and version it explicitly.
- Deployment pipelines promote dev → test → prod. Note that internal shortcuts are remapped across stages, but target tables/folders are not created automatically — you create them in the target workspace after deployment.
- Variable libraries eliminate hard-coded Lakehouse IDs, connection strings and parameters, resolving per workspace so promotion needs no manual reconfiguration. This is the supported answer to the hard-coded-abfss problem flagged in Sec 34.
-
Programmatic deployment via
fabric-cicdor the Fabric REST APIs when you need Azure DevOps/GitHub Actions to drive it rather than the portal.
3. Real-time & streaming
| Option | Use when |
|---|---|
| Eventstream | No-code ingestion from Event Hubs, Kafka, IoT and CDC sources into Eventhouse or Lakehouse. The path used in Sec 21 to land Spark diagnostic logs. |
| Eventhouse / KQL | Time-series and log-shaped data at high ingest rates, queried with KQL. Better than Delta for append-heavy telemetry with time-window queries. |
| Structured Streaming on Spark |
When you need Spark transformations on the stream. Remember: streaming falls
back from NEE (Sec 08), and Spark 4.x
brings transformWithState and the State Data Source (Sec 17).
|
| Activator | Event-driven triggering — run on data or file arrival rather than a clock, and alert on capacity/job events. |
4. Ingestion alternatives & the semantic layer
| Tool | Where it beats a notebook |
|---|---|
| Copy job / Copy activity | Straightforward source-to-file/table movement with no bespoke logic — cheaper and simpler than hand-written code (Sec 35). |
| Mirroring | Near-real-time replication of an operational database into OneLake with no pipeline to maintain. Remember it is near-real-time, not synchronous. |
| Dataflow Gen2 | Low-code transformation for citizen developers. Produces the same Delta tables — and therefore the same maintenance needs (Sec 33). |
| dbt-fabric | SQL-first transformation with lineage and testing, for teams already invested in dbt. |
| Direct Lake semantic models | The reason V-Order and file sizing on gold matter (Sec 24). Direct Lake reads Delta files directly rather than importing — but the model must re-frame to pick up new commits, which is why VACUUM ordering matters for gold tables (Sec 33). |
| Star schema at gold | Wide flat tables are convenient for Spark and poor for BI. Model gold as conformed dimensions plus fact tables — the semantic layer's performance ceiling is set here, not in Power BI. |
Expert Learning Path & Milestones
Thirty-six sections is a reference, not a curriculum. This is the order that builds understanding rather than trivia, with a concrete artifact at each stage so learning is verified by doing. Pick your current level below.
Recommended Deep-Dive Sequence (Added This Version)
- Sec 04 · Catalyst Optimizer — Understand how SQL/PySpark becomes a physical plan. Foundation for everything below.
- Sec 06 · Adaptive Query Execution (5-Feature Masterclass) — How Spark self-corrects at runtime. DPP, RSM integration, Skew Join splitting.
- Sec 08 · NEE Fallback Deep Dive — ANSI×NEE matrix: why Runtime 2.0 with NEE enabled can still run entirely on the JVM.
- Sec 24b · Column Stats & SQL Tuning Masterclass — The 32-column skipping trap, maintenance diagnosis, and the SQL-to-Spark Rosetta Stone.
- Sec 24 · Delta Optimization — V-Order, Liquid Clustering, Deletion Vectors, CDF internals.
- Sec 15 · Config Advisor (Medallion Presets) — Generate Bronze/Silver/Gold environment configs with Minimal Delta mode.
- Sec 33 · Table Maintenance Cadence Advisor — Interactive schedule generator for OPTIMIZE, DV purge, and VACUUM.
-
Sec 27 · Query Plan Reading — How to read
df.explain("formatted")and the SQL tab DAG. Bridges everything above.
Comprehensive Master Glossary
CREATE BLOOMFILTER INDEX is a Databricks-proprietary Delta extension
and is not part of OSS Delta Lake, which is what Fabric Spark runs. The Fabric
equivalents are Delta data skipping via
delta.dataSkippingStatsColumns and liquid clustering.
Sec 24b
delta.enableChangeDataFeed=true — records row-level changes
(insert/update_pre/update_post/delete) in sidecar CDC files. Powers incremental
silver→gold processing. Sec 24
minValues/maxValues from the Delta log and eliminates files whose
ranges don't overlap the query filter. Limited to first 32 columns by default.
Sec 24b
REORG TABLE APPLY (PURGE) to physically remove.
Sec 24
CLUSTER BY (col1, col2) — replaces partitioning and Z-ORDER.
Incremental, no cardinality trap, re-clusterable without full rewrite. The
lakehouse equivalent of a Clustered Columnstore Index. GA on Runtime 2.0.
Sec 24