Skip to content

Commit 3610171

Browse files
cigraingerclaude
andauthored
feat: Hive partition pruning in distributed reads (#18)
Skip Parquet files whose Hive partition values (year=2024/month=01/) don't match pipeline filter predicates. Extracts simple equality predicates from filter ops and matches against partition key-value pairs parsed from file paths. Integrated into the Partitioner before size-balanced assignment — fewer files to distribute means better balance and less IO. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3f8b8b3 commit 3610171

5 files changed

Lines changed: 502 additions & 0 deletions

File tree

‎lib/dux/remote/partition_pruner.ex‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
defmodule Dux.Remote.PartitionPruner do
2+
@moduledoc false
3+
4+
# Prunes Hive-partitioned Parquet files based on pipeline filter ops.
5+
#
6+
# Given a list of file paths like:
7+
# ["s3://bucket/year=2024/month=01/data.parquet",
8+
# "s3://bucket/year=2024/month=02/data.parquet",
9+
# "s3://bucket/year=2023/month=12/data.parquet"]
10+
#
11+
# And filter ops like:
12+
# [{:filter, "\"year\" = 2024"}]
13+
#
14+
# Returns only the files where year=2024.
15+
#
16+
# Only handles simple equality predicates on partition columns.
17+
# Complex expressions are ignored (safe — we just read more data).
18+
19+
@doc """
20+
Prune files whose Hive partition values don't match filter predicates.
21+
22+
Returns the filtered list of files. If no partition filters can be
23+
extracted, returns the original list unchanged.
24+
"""
25+
def prune(files, ops) do
26+
filters = extract_partition_filters(ops)
27+
28+
if filters == [] do
29+
files
30+
else
31+
Enum.filter(files, fn file ->
32+
partitions = extract_hive_partitions(file)
33+
matches_all?(partitions, filters)
34+
end)
35+
end
36+
end
37+
38+
# ---------------------------------------------------------------------------
39+
# Hive partition extraction from file paths
40+
# ---------------------------------------------------------------------------
41+
42+
# Extract key=value pairs from a file path.
43+
# "s3://bucket/year=2024/month=01/data.parquet" → %{"year" => "2024", "month" => "01"}
44+
@doc false
45+
def extract_hive_partitions(path) do
46+
path
47+
|> String.split("/")
48+
|> Enum.flat_map(fn segment ->
49+
case String.split(segment, "=", parts: 2) do
50+
[key, value] when key != "" and value != "" -> [{key, value}]
51+
_ -> []
52+
end
53+
end)
54+
|> Map.new()
55+
end
56+
57+
# ---------------------------------------------------------------------------
58+
# Filter predicate extraction from ops
59+
# ---------------------------------------------------------------------------
60+
61+
# Extract simple equality predicates from filter ops that match partition columns.
62+
# Returns [{column_name, value}] for predicates like:
63+
# "year" = 2024 → {"year", "2024"}
64+
# "month" = '01' → {"month", "01"}
65+
# year = 2024 → {"year", "2024"}
66+
defp extract_partition_filters(ops) do
67+
ops
68+
|> Enum.flat_map(fn
69+
{:filter, expr} when is_binary(expr) -> parse_equality_predicates(expr)
70+
_ -> []
71+
end)
72+
end
73+
74+
# Parse simple equality predicates from a SQL WHERE clause string.
75+
# Handles: col = val, "col" = val, col = 'val', and AND-connected predicates.
76+
defp parse_equality_predicates(expr) do
77+
# Split on AND (case-insensitive) to handle compound filters
78+
expr
79+
|> String.split(~r/\bAND\b/i)
80+
|> Enum.flat_map(&parse_single_predicate/1)
81+
end
82+
83+
defp parse_single_predicate(predicate) do
84+
trimmed = String.trim(predicate)
85+
86+
# Match patterns like: "col" = val, col = val, "col" = 'val'
87+
# Captures: optional quotes around column name, = operator, value
88+
case Regex.run(
89+
~r/^"?([a-zA-Z_][a-zA-Z0-9_]*)"?\s*=\s*(.+)$/,
90+
trimmed
91+
) do
92+
[_, column, raw_value] ->
93+
value = clean_value(String.trim(raw_value))
94+
[{column, value}]
95+
96+
_ ->
97+
[]
98+
end
99+
end
100+
101+
# Strip quotes and whitespace from a SQL value literal
102+
defp clean_value(val) do
103+
val
104+
|> String.trim("'")
105+
|> String.trim("\"")
106+
|> String.trim()
107+
end
108+
109+
# ---------------------------------------------------------------------------
110+
# Matching
111+
# ---------------------------------------------------------------------------
112+
113+
# Check if a file's partition values match ALL filter predicates.
114+
# If the file has no partition for a given filter column, it passes
115+
# (the filter may apply to a non-partition column).
116+
defp matches_all?(partitions, filters) do
117+
Enum.all?(filters, fn {col, expected} ->
118+
case Map.get(partitions, col) do
119+
nil -> true
120+
actual -> actual == expected
121+
end
122+
end)
123+
end
124+
end

‎lib/dux/remote/partitioner.ex‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule Dux.Remote.Partitioner do
22
@moduledoc false
33

4+
alias Dux.Remote.PartitionPruner
5+
46
# Assigns data partitions to workers.
57
#
68
# For Parquet glob sources and DuckLake file manifests, splits files
@@ -57,6 +59,18 @@ defmodule Dux.Remote.Partitioner do
5759
# ---------------------------------------------------------------------------
5860

5961
defp distribute_parquet_files(files, workers, pipeline, source_opts, assign_opts) do
62+
# Prune files whose Hive partition values don't match pipeline filters
63+
files = PartitionPruner.prune(files, pipeline.ops)
64+
65+
if files == [] do
66+
# All files pruned — return empty result from one worker
67+
[{hd(workers), %{pipeline | source: {:sql, "SELECT 1 WHERE false"}}}]
68+
else
69+
distribute_pruned_files(files, workers, pipeline, source_opts, assign_opts)
70+
end
71+
end
72+
73+
defp distribute_pruned_files(files, workers, pipeline, source_opts, assign_opts) do
6074
files_with_sizes = fetch_file_sizes(files, assign_opts)
6175

6276
assignments =

‎test/dux/coordinator_test.exs‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,116 @@ defmodule Dux.CoordinatorTest do
307307
end
308308
end
309309

310+
describe "Partitioner partition pruning" do
311+
test "prunes Hive-partitioned files based on filter ops" do
312+
dir = tmp_path("hive_pruning")
313+
File.mkdir_p!(dir)
314+
315+
try do
316+
# Create Hive-partitioned structure
317+
for year <- [2023, 2024], month <- ["01", "02"] do
318+
part_dir = Path.join([dir, "year=#{year}", "month=#{month}"])
319+
File.mkdir_p!(part_dir)
320+
321+
Dux.from_list([%{"x" => year * 100 + String.to_integer(month)}])
322+
|> Dux.to_parquet(Path.join(part_dir, "data.parquet"))
323+
end
324+
325+
pipeline =
326+
Dux.from_parquet(Path.join(dir, "**/*.parquet"))
327+
|> Dux.filter_with("year = 2024")
328+
329+
workers = [:w1, :w2]
330+
assignments = Partitioner.assign(pipeline, workers)
331+
332+
# Should only have year=2024 files (2 files), not year=2023
333+
all_files =
334+
Enum.flat_map(assignments, fn {_w, p} ->
335+
case p.source do
336+
{:parquet, f, _} -> [f]
337+
{:parquet_list, f, _} -> f
338+
end
339+
end)
340+
341+
assert length(all_files) == 2
342+
assert Enum.all?(all_files, &String.contains?(&1, "year=2024"))
343+
after
344+
File.rm_rf!(dir)
345+
end
346+
end
347+
348+
test "prunes on multiple partition columns" do
349+
dir = tmp_path("hive_multi_prune")
350+
File.mkdir_p!(dir)
351+
352+
try do
353+
for year <- [2023, 2024], month <- ["01", "02"] do
354+
part_dir = Path.join([dir, "year=#{year}", "month=#{month}"])
355+
File.mkdir_p!(part_dir)
356+
357+
Dux.from_list([%{"x" => 1}])
358+
|> Dux.to_parquet(Path.join(part_dir, "data.parquet"))
359+
end
360+
361+
pipeline =
362+
Dux.from_parquet(Path.join(dir, "**/*.parquet"))
363+
|> Dux.filter_with("year = 2024 AND month = '01'")
364+
365+
workers = [:w1, :w2]
366+
assignments = Partitioner.assign(pipeline, workers)
367+
368+
all_files =
369+
Enum.flat_map(assignments, fn {_w, p} ->
370+
case p.source do
371+
{:parquet, f, _} -> [f]
372+
{:parquet_list, f, _} -> f
373+
end
374+
end)
375+
376+
assert length(all_files) == 1
377+
assert hd(all_files) |> String.contains?("year=2024")
378+
assert hd(all_files) |> String.contains?("month=01")
379+
after
380+
File.rm_rf!(dir)
381+
end
382+
end
383+
384+
test "no pruning when filter doesn't match partition columns" do
385+
dir = tmp_path("hive_no_prune")
386+
File.mkdir_p!(dir)
387+
388+
try do
389+
for year <- [2023, 2024] do
390+
part_dir = Path.join(dir, "year=#{year}")
391+
File.mkdir_p!(part_dir)
392+
393+
Dux.from_list([%{"x" => year}])
394+
|> Dux.to_parquet(Path.join(part_dir, "data.parquet"))
395+
end
396+
397+
pipeline =
398+
Dux.from_parquet(Path.join(dir, "**/*.parquet"))
399+
|> Dux.filter_with("x > 100")
400+
401+
workers = [:w1, :w2]
402+
assignments = Partitioner.assign(pipeline, workers)
403+
404+
all_files =
405+
Enum.flat_map(assignments, fn {_w, p} ->
406+
case p.source do
407+
{:parquet, f, _} -> [f]
408+
{:parquet_list, f, _} -> f
409+
end
410+
end)
411+
412+
# x is not a partition column, so all files pass through
413+
assert length(all_files) == 2
414+
after
415+
File.rm_rf!(dir)
416+
end
417+
end
418+
end
419+
310420
# ---------------------------------------------------------------------------
311421
# Merger unit tests
312422
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)