Skip to content

Commit b2c9204

Browse files
committed
test: add real-world benchmark fixtures
1 parent fbff248 commit b2c9204

14 files changed

Lines changed: 305 additions & 0 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ nextflow run seqeralabs/nf-aggregate \
8484

8585
The benchmark report can be generated without cost data - simply omit the `--benchmark_aws_cur_report` parameter if cost analysis is not needed.
8686

87+
For a checked-in real-world example that exercises external run JSON directories plus a tiny filtered cost parquet, see:
88+
- `workflows/nf_aggregate/assets/test_benchmark_realworld_costs.csv`
89+
- `workflows/nf_aggregate/assets/realworld_log_dirs/`
90+
- `workflows/nf_aggregate/assets/test_benchmark_realworld_costs.parquet`
91+
- `tests/pipeline_benchmark_realworld_costs/main.nf.test`
92+
93+
If you want to regenerate the tiny parquet locally from a monthly CUR export while stripping out every non-benchmark real cost row, run:
94+
95+
```
96+
python scripts/build_filtered_cost_sidecar.py \
97+
/path/to/scidev-detailed-usage-YYYY-MM.snappy.parquet \
98+
--run-ids-csv workflows/nf_aggregate/assets/test_benchmark_realworld_costs.csv \
99+
--output workflows/nf_aggregate/assets/test_benchmark_realworld_costs.parquet
100+
```
101+
102+
Add `--include-red-herring` only if you want one synthetic non-benchmark row for robustness testing.
103+
87104
## Output
88105

89106
The results from the pipeline will be published in the path specified by the `--outdir` and will consist of the following contents:
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import csv
7+
from pathlib import Path
8+
9+
import pyarrow as pa
10+
import pyarrow.parquet as pq
11+
12+
RUN_ID_COLUMNS = [
13+
'resource_tags_user_unique_run_id',
14+
'resource_tags_user_nf_unique_run_id',
15+
]
16+
RUN_ID_TAG_KEYS = [
17+
'user_unique_run_id',
18+
'user_nf_unique_run_id',
19+
]
20+
KEEP_COLUMNS = [
21+
'line_item_usage_start_date',
22+
'line_item_usage_end_date',
23+
'line_item_product_code',
24+
'line_item_line_item_type',
25+
'line_item_resource_id',
26+
'product_instance_type',
27+
'product_product_family',
28+
'resource_tags',
29+
'line_item_unblended_cost',
30+
'line_item_net_unblended_cost',
31+
'split_line_item_split_cost',
32+
'split_line_item_net_split_cost',
33+
'split_line_item_unused_cost',
34+
'split_line_item_net_unused_cost',
35+
]
36+
37+
38+
def load_run_rows(csv_path: Path) -> list[dict[str, str]]:
39+
with csv_path.open() as handle:
40+
return list(csv.DictReader(handle))
41+
42+
43+
def choose_run_id_column(schema: pa.Schema) -> str | None:
44+
for name in RUN_ID_COLUMNS:
45+
if name in schema.names:
46+
return name
47+
return None
48+
49+
50+
def build_run_ids(table: pa.Table, run_id_column: str | None) -> list[str | None]:
51+
if run_id_column is not None:
52+
return table[run_id_column].combine_chunks().to_pylist()
53+
if 'resource_tags' not in table.schema.names:
54+
raise SystemExit('No supported run-id column or resource_tags map found in parquet schema')
55+
56+
values: list[str | None] = []
57+
for raw_tags in table['resource_tags'].to_pylist():
58+
tag_map = dict(raw_tags or [])
59+
run_id = None
60+
for key in RUN_ID_TAG_KEYS:
61+
if key in tag_map:
62+
run_id = tag_map[key]
63+
break
64+
values.append(run_id)
65+
return values
66+
67+
68+
def main() -> None:
69+
parser = argparse.ArgumentParser(
70+
description='Build a tiny parquet sidecar containing only the target benchmark run costs from a monthly CUR export.'
71+
)
72+
parser.add_argument('cur_parquet', type=Path, help='Path to the monthly CUR parquet file')
73+
parser.add_argument('--run-ids-csv', type=Path, required=True, help='CSV containing benchmark run IDs')
74+
parser.add_argument('--output', type=Path, required=True, help='Output parquet path')
75+
parser.add_argument(
76+
'--include-red-herring',
77+
action='store_true',
78+
help='Append a single synthetic non-benchmark row to exercise downstream filtering.',
79+
)
80+
args = parser.parse_args()
81+
82+
run_rows = load_run_rows(args.run_ids_csv)
83+
run_lookup = {row['id']: {'group': row.get('group', 'default')} for row in run_rows}
84+
target_run_ids = set(run_lookup)
85+
86+
table = pq.read_table(args.cur_parquet)
87+
run_id_column = choose_run_id_column(table.schema)
88+
run_ids = build_run_ids(table, run_id_column)
89+
keep_columns = [name for name in KEEP_COLUMNS if name in table.schema.names]
90+
column_values = {name: table[name].combine_chunks().to_pylist() for name in keep_columns}
91+
92+
rows: list[dict] = []
93+
for idx, run_id in enumerate(run_ids):
94+
if run_id not in target_run_ids:
95+
continue
96+
row = {
97+
'fixture_run_id': run_id,
98+
'fixture_group': run_lookup[run_id]['group'],
99+
'fixture_run_id_source': run_id_column or 'resource_tags.' + '|'.join(RUN_ID_TAG_KEYS),
100+
}
101+
for name in keep_columns:
102+
row[name] = column_values[name][idx]
103+
rows.append(row)
104+
105+
if args.include_red_herring:
106+
rows.append(
107+
{
108+
'fixture_run_id': 'red-herring-run-id',
109+
'fixture_group': 'red-herring',
110+
'fixture_run_id_source': 'synthetic',
111+
'line_item_product_code': 'SyntheticCost',
112+
'line_item_line_item_type': 'Usage',
113+
'line_item_resource_id': 'synthetic:red-herring',
114+
'product_instance_type': None,
115+
'product_product_family': 'Synthetic',
116+
'resource_tags': [('user_unique_run_id', 'red-herring-run-id')],
117+
'line_item_unblended_cost': 0.123456789,
118+
'line_item_net_unblended_cost': 0.123456789,
119+
'split_line_item_split_cost': 0.123456789,
120+
'split_line_item_net_split_cost': 0.123456789,
121+
'split_line_item_unused_cost': 0.000000001,
122+
'split_line_item_net_unused_cost': 0.000000001,
123+
'line_item_usage_start_date': None,
124+
'line_item_usage_end_date': None,
125+
}
126+
)
127+
128+
if not rows:
129+
raise SystemExit('No matching benchmark rows found in the supplied CUR parquet')
130+
131+
args.output.parent.mkdir(parents=True, exist_ok=True)
132+
pq.write_table(pa.Table.from_pylist(rows), args.output)
133+
print(args.output)
134+
print(f'rows={len(rows)}')
135+
print(f'run_ids={sorted({row["fixture_run_id"] for row in rows})}')
136+
137+
138+
if __name__ == '__main__':
139+
main()

tests/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Each pipeline-level scenario lives in its own directory:
77
- `pipeline_mixed_no_benchmark/`
88
- `pipeline_benchmark_tarball/`
99
- `pipeline_benchmark_directory/`
10+
- `pipeline_benchmark_realworld_costs/`
1011

1112
Each scenario directory contains:
1213
- `main.nf.test` — the nf-test scenario
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# pipeline_benchmark_realworld_costs
2+
3+
## Purpose
4+
Covers benchmark generation from real-world external run JSON directories plus a tiny filtered CUR parquet sidecar.
5+
6+
## Fixtures
7+
- `workflows/nf_aggregate/assets/test_benchmark_realworld_costs.csv`
8+
- JSON directories under `workflows/nf_aggregate/assets/realworld_log_dirs/`
9+
- `workflows/nf_aggregate/assets/test_benchmark_realworld_costs.parquet`
10+
11+
## Expected behavior
12+
- `EXTRACT_TARBALL` must not run.
13+
- Benchmark stages should consume the external directories directly.
14+
- The cost parquet should populate `jsonl_bundle/costs.jsonl` and aggregated `run_costs`.
15+
- The synthetic red-herring row inside the parquet must not appear in `report_data.json` because it has no matching run JSON input.
16+
17+
## Edit guidance
18+
If you change cost normalization, external-directory handling, or benchmark report aggregation, update this scenario along with the real-world fixture inputs.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
def INPUT_PATH = new File('workflows/nf_aggregate/assets/test_benchmark_realworld_costs.csv').canonicalPath
2+
def CUR_PATH = new File('workflows/nf_aggregate/assets/test_benchmark_realworld_costs.parquet').canonicalPath
3+
4+
def expectedOutputNames = [
5+
'benchmark_report',
6+
'benchmark_report/benchmark_report.html',
7+
'benchmark_report/jsonl_bundle',
8+
'benchmark_report/jsonl_bundle/costs.jsonl',
9+
'benchmark_report/jsonl_bundle/metrics.jsonl',
10+
'benchmark_report/jsonl_bundle/runs.jsonl',
11+
'benchmark_report/jsonl_bundle/tasks.jsonl',
12+
'benchmark_report/report_data.json',
13+
'pipeline_info',
14+
'pipeline_info/collated_software_versions.yml',
15+
]
16+
17+
def collatedVersions = { outdir ->
18+
new File("${outdir}/pipeline_info/collated_software_versions.yml").text
19+
}
20+
21+
nextflow_pipeline {
22+
name "Pipeline benchmark from real-world external directories with costs"
23+
script "../../main.nf"
24+
tag "pipeline"
25+
tag "nf-aggregate"
26+
tag "benchmark-realworld-costs"
27+
28+
test("benchmark report includes real-world external runs and ignores red-herring costs") {
29+
tag("benchmark")
30+
tag("costs")
31+
32+
when {
33+
params {
34+
input = INPUT_PATH
35+
outdir = "$outputDir"
36+
generate_benchmark_report = true
37+
benchmark_aws_cur_report = CUR_PATH
38+
}
39+
}
40+
41+
then {
42+
def versions = collatedVersions(params.outdir)
43+
def report = new File("${params.outdir}/benchmark_report/benchmark_report.html")
44+
def reportData = new File("${params.outdir}/benchmark_report/report_data.json")
45+
def costsJsonl = new File("${params.outdir}/benchmark_report/jsonl_bundle/costs.jsonl")
46+
def reportText = report.text
47+
def reportJson = new groovy.json.JsonSlurper().parse(reportData)
48+
def costRunIds = reportJson.run_costs.collect { it.run_id }
49+
50+
assert workflow.success
51+
assert workflow.trace.succeeded().size() == 3
52+
assert workflow.trace.succeeded().any { it.name.contains('NORMALIZE_BENCHMARK_JSONL') }
53+
assert workflow.trace.succeeded().any { it.name.contains('AGGREGATE_BENCHMARK_REPORT_DATA') }
54+
assert workflow.trace.succeeded().any { it.name.contains('RENDER_BENCHMARK_REPORT') }
55+
assert !workflow.trace.succeeded().any { it.name.contains('EXTRACT_TARBALL') }
56+
assert versions.contains('NORMALIZE_BENCHMARK_JSONL:')
57+
assert versions.contains('AGGREGATE_BENCHMARK_REPORT_DATA:')
58+
assert versions.contains('RENDER_BENCHMARK_REPORT:')
59+
assert report.isFile()
60+
assert reportData.isFile()
61+
assert costsJsonl.isFile()
62+
assert reportText.contains('nf-core/sarek')
63+
assert reportText.contains('nf-core/rnaseq')
64+
assert reportText.contains('nf-core/methylseq')
65+
assert reportText.contains('3dLeF2W4ju5og4')
66+
assert reportText.contains('10Sj9ejvKktHWg')
67+
assert reportText.contains('wELtvsveirfd9')
68+
assert reportJson.cost_overview.size() > 0
69+
assert reportJson.benchmark_overview.size() == 6
70+
assert reportJson.run_costs.size() == 6
71+
assert costRunIds.containsAll(['3dLeF2W4ju5og4', '1xpti5PH3K2VUW', '5yJURpCMUNmfri', '10Sj9ejvKktHWg', '50CueCkLKqyOeH', 'wELtvsveirfd9'])
72+
assert !costRunIds.contains('red-herring-run-id')
73+
assert reportJson.run_costs.find { it.run_id == '3dLeF2W4ju5og4' }.used_cost > 0
74+
75+
assert snapshot(
76+
workflow.trace.succeeded().size(),
77+
removeFromYamlMap("$outputDir/pipeline_info/collated_software_versions.yml", "Workflow"),
78+
expectedOutputNames
79+
).match()
80+
}
81+
}
82+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"benchmark report includes real-world external runs and ignores red-herring costs": {
3+
"content": [
4+
3,
5+
{
6+
"AGGREGATE_BENCHMARK_REPORT_DATA": {
7+
"python": "3.12.13"
8+
},
9+
"NORMALIZE_BENCHMARK_JSONL": {
10+
"python": "3.12.13"
11+
},
12+
"RENDER_BENCHMARK_REPORT": {
13+
"python": "3.12.13"
14+
}
15+
},
16+
[
17+
"benchmark_report",
18+
"benchmark_report/benchmark_report.html",
19+
"benchmark_report/jsonl_bundle",
20+
"benchmark_report/jsonl_bundle/costs.jsonl",
21+
"benchmark_report/jsonl_bundle/metrics.jsonl",
22+
"benchmark_report/jsonl_bundle/runs.jsonl",
23+
"benchmark_report/jsonl_bundle/tasks.jsonl",
24+
"benchmark_report/report_data.json",
25+
"pipeline_info",
26+
"pipeline_info/collated_software_versions.yml"
27+
]
28+
],
29+
"timestamp": "2026-04-21T08:57:54.57349",
30+
"meta": {
31+
"nf-test": "0.9.5",
32+
"nextflow": "26.03.2"
33+
}
34+
}
35+
}

workflows/nf_aggregate/assets/realworld_log_dirs/10Sj9ejvKktHWg/10Sj9ejvKktHWg.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

workflows/nf_aggregate/assets/realworld_log_dirs/1xpti5PH3K2VUW/1xpti5PH3K2VUW.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

workflows/nf_aggregate/assets/realworld_log_dirs/3dLeF2W4ju5og4/3dLeF2W4ju5og4.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

workflows/nf_aggregate/assets/realworld_log_dirs/50CueCkLKqyOeH/50CueCkLKqyOeH.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)