WorkingDirectory icon
PythonSubmit icon
Docker icon
Log icon
If icon
Fail icon
Request icon
Pause icon
Loop icon
Schedule icon

Spark Small File Compaction Guard

Detect fragmented Hive partitions, compact them with Spark through a staged atomic swap and prove every row survived.

Categories
DataInfrastructure

Safety

A streaming or micro-batch writer leaves a Hive-partitioned Parquet table with hundreds of tiny files per partition. Every later query pays for opening them. The usual fix is one line: df.repartition(n).write.mode("overwrite").parquet(partition_path). On a plain directory table that line is the most dangerous statement in the data platform. There is no transaction log and no snapshot. Spark deletes the partition directory first, then writes the new files, so the partition does not exist for the length of the job. If the job is killed, the cluster loses a node or the source DataFrame is still lazily pointing at the path being deleted, the partition is gone and no rollback exists. This flow never overwrites a live path. It builds the compacted copy somewhere else, proves it holds the same rows, then swaps it in with two atomic renames.

Guard What it stops
Staged write into _compaction/staging, never onto the live path the delete-then-write window that loses a partition when a job dies
Row count plus two order-independent checksums compared before the swap publishing a copy that silently dropped or changed rows
Two atomic rename calls instead of a copy a reader ever seeing a partially written partition
The old files kept under _compaction/backup until verification passes having nothing to roll back to
quiet_minutes on the newest file mtime rewriting a partition that is still being appended to
protected_partitions touching a partition another team owns
min_data_files, max_avg_file_pct and max_partition_mb rewriting partitions that are already fine or too big to rewrite safely
Any unexpected file in a partition makes it ineligible rewriting a layout the flow does not understand
auto_remediate false, dry_run true, Pause three separate gates before one write
Fingerprint recheck after the approval plus a third read immediately before the swap acting on a plan that went stale while the approval waited
Loop with concurrencyLimit: 1 two partitions racing for the same staging area
Residue reconciliation at the start of every run leaving a half-finished pass to rot

How it works

  1. detect (WorkingDirectory) runs scan_table (io.kestra.plugin.spark.PythonSubmit). It lists the table root through the Hadoop FileSystem API. For every <partition_column>= directory it reads the data file count, total bytes, average file size, newest mtime, a SHA-256 digest of the file listing, the row count and two checksums.
  2. The same task first reconciles leftovers. A partition that is missing or empty while a copy sits under _compaction/backup is restored from that copy in any mode. A backup whose rows and checksums match the live partition belongs to a swap that already committed, so it is removed outside dry run. A backup that disagrees with the live partition is a conflict: residue_conflict fails the run through conflicting_residue rather than guessing.
  3. report logs the plan and, for every spared partition, the first reason it was spared. incident (If) continues only when the plan is not empty and posts it to Slack (finding).
  4. change_enabled (If) requires auto_remediate: true and dry_run: false. Otherwise report_only prints the file counts that would be rewritten and the run ends.
  5. approval (Pause) waits for a person. approved_change reads onResume.approved ?? false, so a resume with no answer is a refusal.
  6. recheck runs the identical detector again (rescan_table) and compares its plan fingerprint, which carries file counts, bytes, mtimes, rows and checksums, with the approved one. unchanged_plan (If) also compares the partition count, the planned row total and the table path. Anything else fails the run at stale_approval before a single byte is written.
  7. compact_partitions (Loop, concurrencyLimit: 1) rewrites one partition at a time in stage_and_swap: read the live partition a third time and compare the fingerprint, write repartition(target_files) into _compaction/staging/<partition>, compare rows and both checksums against the live partition, confirm the copy really is less fragmented, compare the file listing digest once more, then rename live to _compaction/backup/<partition> and rename the staged copy onto the live path. The published partition is read back. On any mismatch the backup is renamed straight back and the task fails. The backup is deleted only after the read-back passes.
  8. verify (audit_table) re-reads the table from scratch. Planned partitions must hold their exact rows and checksums with fewer files, partitions spared for any reason other than active writes must be byte identical, partitions spared as active may only grow and _compaction must be empty. outcome (If) additionally compares the row count and both checksums summed over every quiet partition against the values read before the change. It also requires the table-wide data file count to have dropped. Otherwise unresolved fails the run.

The nightly_layout_check schedule ships disabled: true with dry_run: true and auto_remediate: false, so enabling it can only produce a report.

What you get

  • A per-partition fragmentation report from the file system itself, with the reason every spared partition was spared.
  • Compaction that is approved per plan and applied one partition at a time.
  • A conservation proof: identical row count and two checksums before and after, per partition and summed over the table.
  • A failed run, not a silent one, when a write is refused, a copy does not match or a partition is still fragmented afterwards.
  • A table that an interrupted or refused run leaves intact, plus a next run that reconciles it.

Who it's for

Data platform and lakehouse teams running Spark over partitioned Parquet directories without a table format, where compaction is a hand-written overwrite nobody wants to be on call for.

Why orchestrate this with Kestra

Compaction is not one job: it is a measurement, a decision, an approval, a bounded write and a proof. The dangerous part is the gap between them. Kestra holds the measured plan across a Pause, reads the table again on the other side, serialises the rewrites then fails the execution when the proof does not hold. The guard and the evidence live in the same file as the Spark code, so the next operator can read why a partition was spared instead of reconstructing it from logs.

Tested end to end

Kestra 2.0.5, Spark plugin 1.2.9, Spark 4.0.1 standalone with one worker, Apache Hadoop 3.4.1 HDFS with replication 1. Validate was clean: no constraints, warnings or deprecations. Every number below was read back with hdfs dfs plus a separate spark-submit that never goes through Kestra. The fixture table held 7 partitions, 324,495 rows and 961 data files: two fragmented partitions (320 and 180 files averaging 2,304 bytes), a healthy one (2 files of 2.8 MB), a lookalike with many files but a healthy average (60 files of 305 KB), a boundary partition one file below min_data_files, a protected one and one still being written to. All eight cases below ran on the final revision 2 with target_file_mb: 1, min_data_files: 50 and quiet_minutes: 60.

Case Execution Result
Healthy 67ZznzUpaTAKdm5M2JhvwI SUCCESS. A second table of 2 partitions and 3 data files produced PLAN none
Dry run 2KMdymYCzKlES5gZbOxlpS SUCCESS. Planned dt=2026-10-10 and dt=2026-10-11 only, spared the other five with one reason each, wrote nothing. 961 data files afterwards
Remediation 3uPrXH5xVr5RjMV7omGA44 SUCCESS. 961 to 463 data files, 500 tiny files became 2. hdfs dfs -count went from 968 to 470 files. Average file in dt=2026-10-10 grew from 2,304 to 128,049 bytes. All 324,495 rows, checksum 162,334,318,880 and amount total 8,099,402.65 unchanged
Repeat 1cJX14kpztoMpW56pIB1xE SUCCESS. PLAN none, the two compacted partitions now spare themselves on few_data_files, no second rewrite
Declined approval 6vyk2ojmQegFLuOjqmJl8D SUCCESS. With the protection removed dt=2026-10-15 was the plan. Resumed approved=false, so the partition kept its 200 files and 460,932 bytes
Change during the Pause 2vKYLPrEI7EWtZmSYQmYs2 FAILED as intended. A writer landed a 201st file in the planned partition while the approval waited. rescan_table saw a different fingerprint and stale_approval refused before any write
Refused write 1BSr0bcfBh92Kre76Zj0nH FAILED as intended. A 200,000 byte HDFS space quota on the staging directory made the staged copy raise DSQuotaExceededException. The guard failed the run, left the live partition at 200 files and cleaned the staging directory
Interrupted pass eruQgAGncFeDI6UmgJ4sv SUCCESS. A swap was crashed by hand between the two renames, leaving dt=2026-10-14 only under _compaction/backup. The next run restored it in read-only mode: 49 files, 112,900 bytes, 245 rows, checksum 125,922,802

Protected and lookalike partitions were spared in every run: the 2-file healthy partition, the 60-file partition with a 305 KB average, the 49-file boundary against min_data_files: 50, the partition named in protected_partitions and the partition still inside the quiet window. The independent spark-submit audit after the whole sweep matched the seeded fixture per partition, byte for byte on the five spared ones and row for row on the two rewritten ones. Slack was a local HTTP sink and the errors block fired on both failing runs.

A second, freshly seeded HDFS namenode repeated the three cases that matter, with its own hdfs dfs and spark-submit reads: healthy SSUPjLuatXKyueDd05kgp, remediation 6LjP2Hv9OOThfbqWeNU48B (the same 961 to 463 data files, 320 and 180 files became 1 each, the same 324,495 rows and checksum 162,334,318,880) and a pause race 3B7oEIg7CMqEbVfD3kbvgB where a 201st file landed in dt=2026-10-15 and stale_approval refused before any write.

Prerequisites

  • A Hive-partitioned Parquet table on a Hadoop compatible file system that supports atomic directory rename. Tested on HDFS 3.4.1. Object stores without atomic rename are out of scope.
  • Spark 4.0.1 reachable from Kestra plus the Kestra Spark plugin.
  • Minimum permissions: read on the table root, plus write, rename and delete inside it. Nothing outside the table root is touched.

Secrets

  • SPARK_MASTER_URL: Spark master, for example spark://master:7077.
  • LAKE_TABLE_PATH: root of the table, for example hdfs://nn:8020/lake/events.
  • SLACK_WEBHOOK_URL: Slack incoming webhook for findings and failures.

Inputs

Input Default Purpose
partition_column dt Only <column>=<value> directories are considered
target_file_mb 128 Wanted file size. Sets the threshold and the output file count
max_avg_file_pct 25 Fragmented means the average file is under this share of the target
min_data_files 50 Fewest files worth a rewrite
max_partition_mb 2048 Bounded change. Larger partitions are reported only
quiet_minutes 60 Partitions written to more recently are spared
max_partitions 5 Most partitions one approved run may rewrite
protected_partitions [] Partition directories that are never rewritten
dry_run true Report only
auto_remediate false Ask for approval to compact

Quick start

  1. Add the three secrets. The Spark account needs read, write, rename and delete inside the table root only.
  2. Execute with the defaults. Read the report: the plan plus the reason beside every spared partition.
  3. Tune target_file_mb and min_data_files to the table, then list partitions other teams own in protected_partitions.
  4. Execute with dry_run: false and auto_remediate: true, then approve the Pause.
  5. Read the verification line: file counts before and after, rows kept and the checksum that did not move.
  6. Enable nightly_layout_check for a daily report.

How to extend

  • Point the flow at several tables from a parent flow, so each table keeps its own plan, approval and proof.
  • Add a Z-order or sort inside the staged write by sorting the DataFrame before repartition then compare query times across the swap.
  • Send the plan to a ticket instead of Slack then resume the Pause from the ticket's approval.
  • Keep the backup copy for a retention window instead of deleting it, by skipping the final delete and letting a later run reconcile it.

Limits: the flow handles one partition column and one table per execution. It needs a file system with atomic directory rename, so plain object stores are out of scope. The staged copy doubles the partition's space for the length of the rewrite. Row order inside a partition is not preserved and the checksums are order independent by design. A compaction changes file mtimes, so a downstream job that keys on mtime sees the partition as new. Table formats such as Delta, Iceberg and Hudi have their own transactional compaction and do not need this.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.