Enable Delta optimized writes + lower shuffle partition default#396
Enable Delta optimized writes + lower shuffle partition default#396kathrynalpert wants to merge 2 commits into
Conversation
Dependency Review✅ No vulnerabilities or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
dev05 ingest vs. these metricsI gathered comparable numbers from a recent 100k-synthetic ingest on the dev05 cluster. TL;DR: the per-workflow durations aren't apples-to-apples with the dev04 numbers above, because dev05 ran all 24 transform workflows concurrently against a 2 GB-memory transformer that OOM-restarted mid-run, vs. dev04's sequential 32 GB run. What was run
Per-workflow durations (Temporal start→close)
Why these aren't apples-to-apples
Net: dev05's per-WF durations reflect queue contention + OOM retries on an under-provisioned, baseline-config box — they can't be placed in the WF1/WF5/WF6/WF24 progression above. The only roughly-comparable headline is total wall-clock (~18.4 h to ingest 100k under these conditions), and even that is confounded by the OOM restarts. Spark job/task stats — unobtainableThe "median tasks per filter" and "median Spark job duration" rows can't be reproduced for dev05 after the fact:
If we want a genuinely comparable measurement from dev05, the cleanest path is a fresh ingest that mirrors this PR's methodology — sequential workflows, matched Spark memory, and |
The HL7-transformer's Delta tables accumulated thousands of tiny parquet files per workflow (e.g. ~270 files/workflow in the patient mapping table, climbing to 1348 files / 4.5 MB total after 5 ingests). Each subsequent filter then launched ~1300 trivial Spark tasks, doubling per-workflow runtime over 5 ingests (10→20 min). Two intersecting causes: the derivative tables write via Spark Structured Streaming (`writeStream.foreachBatch` in dataextraction.py), which (a) splits a logical write into many micro-batches and (b) doesn't benefit from AQE coalescing — so the static `shuffle.partitions=200` default fanned every write into up to 200 partitions, mostly tiny. Three settings, applied at the session level so they cover both batch and streaming code paths: spark.sql.shuffle.partitions 200 -> 16 spark.databricks.delta.optimizeWrite.enabled true (Delta 3.1+) spark.databricks.delta.autoCompact.enabled true (Delta 3.1+) `spark_sql_shuffle_partitions` can still be overridden per-inventory. Empirically verified against dev04: after a single workflow with the new config, the patient-mapping table dropped from ~270 files/workflow to 5, reports_dx from ~200/workflow to 1, and per-workflow runtime dropped ~40%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original commit's `16` was tuned to dev04's per-workflow volume (~4K HL7 messages per child workflow). Prod's volume is ~375× larger (~1.5M messages per workflow via 150 logs × ~10K messages/log), and the wider derivative tables — `reports_latest` in particular, with the full report payload per row — can push several GB through a single intermediate shuffle. At that scale, 16 partitions lands above Spark's ~100–200 MB per-partition guidance, increasing spill / memory-pressure risk. 32 keeps the partition size in or near that window for prod's wider shuffles, and is still ~6× lower than Spark's 200 default — so the file-count win for the slimmer tables (mapping, dx) is preserved. `optimizeWrite` coalesces the small per-partition data either way, so 16 vs 32 isn't empirically distinguishable at dev04 scale; the choice here is a safer default for prod's larger workflows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
34d7ad4 to
cc01707
Compare
Description
This is a PR more for comment/visibility than for imminent merge.
Product
N/A
Technical
HL7 ingest workflows of historical data get slower as more days are ingested. On 100k synthetic data, per-workflow duration roughly doubled across 5
IngestHl7ToDeltaLakeWorkflowruns. This PR aims to improve ingest performance and reduce the impact of ingesting into a growing table (what appears to be linear growth).From Claude:
This PR sets:
spark.sql.shuffle.partitions: 200 → 32. Spark's 200 default is sized for larger analytical workloads. The derivative tables span a range —reports_latestis on the wide side (a row per latest-version report with the full payload),reports_report_patient_mappingis on the slim side — but in both cases 200 partitions slices the per-shuffle data into chunks too small to be useful, each becoming a tiny output file. 32 keeps shuffles for the wider tables near Spark's recommended ~100-200 MB-per-partition window even at prod's higher per-workflow volume, while still dramatically reducing file count for the slim ones. The transformer's CPU limit doesn't change the analysis much, because the work per partition is small either way.spark.databricks.delta.optimizeWrite.enabled:true. Delta's optimized-write feature: before committing a write, Delta does a small repartition to pack rows into fewer, larger files. Acts as a safety net if any individual write would still produce too many small files.spark.databricks.delta.autoCompact.enabled:true. After a write, Delta will run a background compaction operation if it sees ≥50 small files in the table. This keeps the file count from drifting upward over many workflows even if individual writes happen to produce a few small files each.The two
spark.databricks.delta.*settings are documented OSS Delta Lake features — see Delta Lake Optimize, which explicitly lists them as Spark SQL session configurations available since Delta 3.1.0. (Thedatabricks.prefix is historical; both settings work in OSS Delta.)Per-workflow volume varies substantially across environments — dev04 is ~4K HL7 messages per child workflow, prod is ~1.5M (each log file has ~10K messages, batched 150 at a time by
hl7log_extractor_concurrency's "continue-as-new" loop). Across that range, the wider derivative tables can push several GB of intermediate shuffle data on a single prod workflow. 32 partitions lands those shuffles in or near Spark's recommended 100–200 MB-per-partition window for prod's wider cases, and is dramatically better than 200 for dev04's smaller workloads. Ifhl7log_extractor_concurrencyitself is significantly raised later, this default should be revisited; cleaner long-term would be for the extractor to pass a derivedshuffle.partitionsthrough to the transformer alongside the batch size, but that seemed beyond the scope of this PR that we may not even merge...Type of change
Impact
Security
Authorization
N/A
Appsec
N/A
Performance
Two runs on dev04 against the same daily HL7 batches: a baseline run on the previous defaults (cancelled at WF 6 due to file accumulation and slowdown — see below) and a full 24-workflow run with this PR's settings.
reports_report_patient_mappingfiles at end of runreports_dxfiles at end of runWith this PR, the full 24-workflow run completed cleanly: no pod restarts, no Spark errors. File accumulation rates per workflow dropped substantially (mapping table at 86 files after 24 WFs vs 1,348 files after just 5 in the baseline), and per-workflow time growth followed a two-phase pattern: linear ~+1.5 min/WF during the first 14 workflows, then a plateau at 28–32 min from WF 15 through WF 24. Row counts verified via Trino:
reports,reports_curated, andreports_latestall hold the expected 100,000 rows — no data loss or duplication.A caveat on causation: the baseline run was cancelled before it could plateau or fail, so we can't say with certainty that small-file accumulation was the primary driver of its time growth. However, both were observed together, and both improve with this PR's settings. We also can't tell from logs alone whether the plateau in the new run reflects
autoCompactengaging (noautoCompact/OptimizeTableentries surfaced, and the file count kept growing past the 50-file threshold rather than dropping) or natural saturation of the patient mapping table as fewer new patients arrive per batch. Either way, the new run reaches a stable steady state rather than degrading indefinitely.Data
No schema or data-model changes. Optimized writes and auto-compaction are transparent to downstream readers; consumers see the same tables, just backed by fewer, larger files. Compaction is performed by Delta at write time and does not rewrite previously-written data (so existing tables won't be retroactively compacted by this change alone).
Backward compatibility
Compatible: the new settings are session-level Spark configs that apply only to writes performed after the next pod restart. Older Delta files are unaffected. The default change to
spark.sql.shuffle.partitionsonly affects callers that didn't already overridespark_sql_shuffle_partitionsin their inventory.Testing
lake/deltaandlake/hl7wiped, postgresingestDB dropped) →make install-extractor→ ran ingest and recorded per-workflow durations, file counts, and Spark job stats. Verified final row counts.spark.sql.shuffle.partitions=16; this PR lands32as a safer default for prod's higher per-workflow volume. The two values shouldn't be empirically distinguishable at dev04 scale, andoptimizeWritecoalesces the small per-partition data either way.Note for reviewers
THIS IS A DRAFT, needs validation on preprod.
This is a targeted spark-defaults change, not a full performance overhaul. Claude cites the remaining source of per-workflow growth as primarily
existing_mapping_dfscans growing linearly with the mapping table, but I have not explored this claim.I upped memory and CPU to better match prod before the first test
hl7_transformer_spark_memory: 32Gandhl7_transformer_cpu_request: 4,hl7_transformer_cpu_limit: 8latesttable.py:43-45runsOPTIMIZE reports_latestafter every workflow's MERGE. WithautoCompactnow enabled by this PR, that manual OPTIMIZE is largely redundant. It's also the source of some bloat, after the 24-workflow test run,reports_latestholds 1.04 GB on S3 vsreports_curated's 78 MB for the same 100K rows. Claude believes this stems from tombstoned compaction artifacts that the per-workflow OPTIMIZE keeps generating without VACUUM. The queryable content is fine; only S3 storage is bloated. Removing the per-workflow OPTIMIZE (and adding periodic VACUUM regardless) would reclaim the space and simplify the write path.Checklist
pre-commit run --all-files)