Skip to content

ci: run the non-default Spark and Iceberg suites nightly instead of in the merge queue - #5963

Merged
andygrove merged 9 commits into
apache:mainfrom
andygrove:ci/nightly-tier
Sep 15, 2026
Merged

andygrove merged 9 commits into
apache:mainfrom
andygrove:ci/nightly-tier

Conversation

@andygrove

@andygrove andygrove commented Sep 15, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Follow-on to #5870 and #5938. Stacked on #5939: this PR shows that PR's three commits until it merges.

Rationale for this change

Every merge-queue run currently executes three Spark SQL versions, four Iceberg versions and the Linux test matrix against all five Spark profiles. Sampling the last six queue runs on 2026-09-15:

Job group runner-min per queue run
PR Build (Linux), all profiles 470
Spark SQL 4.0 273
Spark SQL 4.1 267
Spark SQL 3.5 208
Iceberg 1.11 166
Iceberg 1.10 138
Iceberg 1.9 133
macOS build 124
Iceberg 1.8 122
PyArrow, Delta, benchmark, lint 22
Total 1923

Spark 3.5, Spark 4.0 and Iceberg 1.8/1.9/1.10 are about 870 of those minutes, roughly 45% of every queue run, and the analysis in #5870 found the old Iceberg versions were the most common reason the queue tier went red on a tree that was fine. The regressions those suites catch are real but rare, and a day's delay in seeing one costs less than paying for the suites on every merge.

What changes are included in this PR?

  • A nightly tier in POLICY (dev/ci/compute-changes.py), fired by a schedule trigger on ci.yml at 06:00 UTC. The queue keeps one Spark version (4.1, the default build profile, all modules) and one Iceberg version (1.11, the only Spark 4.1 coverage). Spark SQL on 3.5 and 4.0, Iceberg 1.8/1.9/1.10 and the linux-test matrix's non-default Spark profiles move to the nightly tier. Their existing run-* labels still opt a pull request in early.
  • The scheduled run diffs main against the commit the last successful scheduled run tested (dev/ci/nightly-base.py looks it up through the Actions API, so the changes job gets actions: read) and is routed by the same FILTERS as every other event. Every commit is covered exactly once, and a red nightly keeps its commits in scope until a green one supersedes it. With no such run, an API error, or a base no longer on main, there is no base it can trust, so it treats every tracked file as changed and runs the whole nightly tier. A day with no merges, or with only documentation changes, runs nothing.
  • With profiles: nightly, pr_build_linux.yml runs only lint, build-native and the linux-test matrix. The lints, Rust tests, Spark build, Celeborn checks and TPC-H/TPC-DS runs carry if: ${{ inputs.profiles != 'nightly' }}, since the queue and the push run already produced those verdicts at the same commit (and the PR tier did, for a run-all-spark-profiles label run). The Cargo cache save is limited to the push run for the same reason.
  • A nightly_report job, on the scheduled event only and only when required_checks is not green, opens an issue labelled ci-nightly-failure linking the run and listing the failed jobs, or comments on the one already open so consecutive red nights accumulate in one place. The label exists in the repository.
  • The concurrency subgroup is keyed on the event name, so a scheduled run at the tip of main never cancels that commit's push run.
  • The linux-test profile selector queue-only is renamed nightly, and the profile rows' tier with it, since those rows no longer run in the queue.
  • check-ci-config.py spells out QUEUE_TIER and NIGHTLY_TIER longhand, adds a schedule case, rejects a POLICY entry that lists both queue and nightly, and gains check_nightly_scope, which pins the profiles != 'nightly' guard the same way the cache-refresh guard is pinned, and check_nightly_base_fallback, which pins the schedule case to the last-green-run lookup and the whole-tree fallback and rejects a base derived from the clock. nightly_report is exempt from required_checks.needs for the same reason docs is.
  • The workflows README, the CI contributor-guide page (three tiers, a "Nightly runs" section), and the Spark SQL, Iceberg and development pages are updated to describe the three tiers.

How are these changes tested?

  • python3 dev/ci/check-ci-config.py passes, including the new schedule case, the merge_group case now asserting the nightly suites are absent, the queue/nightly exclusivity rule and the nightly-scope guard check.
  • Routing the last 24 hours of real changes on apache/main through EVENT_NAME=schedule python3 dev/ci/compute-changes.py reports exactly build_linux_all_profiles, spark_3_5, spark_4_0, iceberg_1_8, iceberg_1_9 and iceberg_1_10 true; an empty diff reports nothing true.
  • python3 dev/ci/linux-test-profiles.py --profiles nightly returns the four non-default profile rows, and an unknown selector is rejected.
  • dev/ci/nightly-base.py run locally against the real API prints nothing (no scheduled run exists yet) and exits 0; against an unreachable endpoint it emits a ::warning:: on stderr and exits 0, so the bash fallback takes over. The same query with event=merge_group returns a sha, which exercises the parsing path.
  • The schedule branch's four paths were exercised end to end against a scratch repository and a local API server, running the exact bash from ci.yml and the real routing script. A successful lookup selects the six nightly outputs; a lookup that returns HEAD (a quiet day) selects none; an HTTP 503 and a base that has left main both select the same six, and nothing outside the nightly tier. Reproducing @sunchao's case on the previous revision — last green nightly 72 hours back, an uncovered source change 60 hours back, documentation only since — selects nothing under HTTP 503, and six outputs after the change.
  • The new check_nightly_base_fallback was mutation-tested: restoring the time window, replacing the nightly-base.py call with a fixed revision, and renaming the schedule case each make check-ci-config.py fail with the reason.
  • actionlint --shellcheck=off and prettier --check pass on the edited files.
  • The nightly_report job cannot run before this lands, since schedule only fires from the default branch and a manual dispatch skips it by design. I will watch the first scheduled run after merge.

Leave the Linux build as the whole PR tier. Spark 4.1 (catalyst and
sql_core) and Iceberg 1.11 were the last test suites still running on
every push to every pull request, and with agent-driven review and
agent-driven replies to review a pull request now goes through several
more push rounds before it is queued. The queue runs both suites against
the merge result before anything lands regardless.

`run-spark-4.1-tests` now gates the whole Spark 4.1 call, so it appears
on both of that job's POLICY outputs; `run-spark-4.1-hive-tests` keeps
selecting only the hive shards. `run-iceberg-tests` opts a pull request
into all four Iceberg versions. Pin the new routing in check-ci-config.py
and update the workflows README and the contributor guide.

Closes apache#5938
…on pull requests

The five Spark profiles in the linux-test matrix cost about the same each
and together are three quarters of what the Linux build spends on a pull
request. Run the Comet test suites against Spark 4.1 only on a pull request
and against every profile in the merge queue, or on a pull request carrying
the new `run-all-spark-profiles` label. The Lint Java matrix still compiles
Spark 3.4/3.5/4.0 on every pull request, so a shim that fails to build is
still caught there; only the runtime suites wait for the queue.

A job-level `if:` cannot see `matrix`, so the profile rows move out of the
workflow into dev/ci/linux-test-profiles.py with a tier each. The `lint`
job runs it with a new `profiles` input and publishes the rows as a job
output that the matrix reads with `fromJSON`, the same way the Spark SQL
reusable workflow selects its module shards. A third POLICY output,
`build_linux_all_profiles`, feeds that input from ci.yml; a `labeled` run
sets only that output and runs just the profiles the PR tier skipped.
check-ci-config.py asserts the two tiers partition the list, that the
`pr` tier is exactly the default profile, and that ci.yml passes the input.
Review feedback: the README should describe the current tiers, not how
they got there. The Lint Java sentence stays because it is current state.
…n the merge queue

The merge queue ran three Spark SQL versions, four Iceberg versions and the
Linux test matrix against every Spark profile on each merge. Spark 3.5,
Spark 4.0 and Iceberg 1.8/1.9/1.10 were about 870 of the 1,900
runner-minutes a queue run cost, and the old Iceberg versions were the most
common reason a queue run went red on a tree that was fine.

Add a "nightly" tier to POLICY, fired by a schedule trigger on ci.yml at
06:00 UTC. The queue keeps one Spark version (4.1, the default profile) and
one Iceberg version (1.11, the only Spark 4.1 coverage). Everything else
moves to the nightly run: Spark SQL on 3.5 and 4.0, Iceberg 1.8/1.9/1.10
and the linux-test matrix's non-default Spark profiles. Their run-* labels
still opt a pull request in early.

The nightly run tests main as it stands, so it skips itself when nothing has
landed since the previous one. A red nightly has no pull request to appear
on, so a nightly_report job opens an issue labelled ci-nightly-failure
listing the failed jobs, or comments on the one already open.

The linux-test profile selector queue-only is renamed nightly, since those
rows no longer run in the queue. check-ci-config.py gains a NIGHTLY_TIER set
and a schedule case so a suite drifting back into the queue fails preflight.
@github-actions github-actions Bot added build Build environment enhancement New feature or request area:Iceberg labels Sep 15, 2026
@andygrove

Copy link
Copy Markdown
Member Author

cc @blaginin

Comment thread .github/workflows/ci.yml Outdated
Comment on lines +231 to +235
head_ts=$(git log -1 --format=%ct)
age=$(( $(date +%s) - head_ts ))
if [[ "$age" -gt $(( 24 * 3600 + 1800 )) ]]; then
echo "HEAD is $(( age / 3600 ))h old; nothing has landed since the last nightly"
export NIGHTLY_STALE=true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

works as a first version, but in the future we maybe can use github api to check past runs explicitly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8533d0c: dev/ci/nightly-base.py now looks up the head sha of the last successful scheduled run through the Actions API and the nightly diffs against that, so every commit is covered by exactly one nightly and a red night keeps its commits in scope until a green one supersedes it. The time window is only the fallback for the first run, an API error, or a base that is no longer on main.

@blaginin blaginin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, can improve on top?

…iles nightly

Diff the scheduled run against main's tip as of the previous tick instead
of treating it as an event with no base. The nightly then goes through
FILTERS like every other event: a quiet day or a docs-only day routes to
nothing, and the NIGHTLY_STALE handshake and the schedule branch in
compute-changes.py go away.

With profiles nightly, pr_build_linux.yml now runs only lint, build-native
and the linux-test matrix. The lints, Rust tests, Spark build and TPC runs
already produced their verdict at the same commit, in the queue and push
run for the nightly and in the PR tier for a label run. check-ci-config.py
pins that guard the same way it pins cache-refresh-only, and the Cargo
cache save is limited to the push run.

Spell out QUEUE_TIER longhand, reject a POLICY entry that lists both queue
and nightly, key the concurrency subgroup on the event name, reduce the
profile selector to one comprehension, and trim repeated comments.
Look up the head sha of the most recent successful scheduled ci.yml run
through the Actions API and diff main against it, so every commit is
covered by exactly one nightly and a red night keeps its commits in scope
until a green one supersedes it. The 24h30m window stays as the fallback
for the first run, an API error, or a base that is no longer on main.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 8533d0cf08cdf813e20ba708c0a4df9f07b4d73b. The new last-green-run lookup fixes the normal path, but one P2 remains in the time-based fallback: an API failure can discard source changes that still need nightly coverage. The inline comment includes a reproduced example and two ways to preserve coverage. No other actionable findings across routing, Linux matrix/dependencies, gating, reporting, and documentation.

Validation:

  • CI configuration checks and actionlint passed on this revision.
  • Seven helper cases and five integrated helper/workflow/router cases were exercised locally. The integrated reproduction ran the actual helper against a local API server and the exact workflow shell: a successful lookup selected all six nightly outputs, while HTTP 503 selected none for the same history.
  • Profile/dependency, schedule-routing, and reporting-script probes also passed for the unchanged portions.

CI status: this head currently reports only a successful label check. The CI suites have not run on this [skip ci] commit. Local validation did not execute a hosted scheduled workflow or create a failure issue.

Comment thread .github/workflows/ci.yml Outdated
Comment on lines +238 to +239
if [[ -z "$prev" ]] || ! git merge-base --is-ancestor "$prev" HEAD 2>/dev/null; then
prev=$(git rev-list -1 --before="24 hours 30 minutes ago" HEAD)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve pending coverage when the nightly baseline is unknown

The successful API lookup now preserves coverage, but this fallback can still lose it. Could we run the full nightly tier when no trustworthy baseline is available, or fail change detection so the run cannot become a successful baseline?

For example, the last green nightly was 72 hours ago, a source change landed 60 hours ago, that change has not passed a nightly, and only documentation changed recently. If the Actions API returns HTTP 503, nightly-base.py warns and exits 0 with no SHA. This fallback selects the 60-hour-old source commit as prev, so the diff contains only documentation and all nightly jobs skip. The run can finish green. Once the API recovers, that new green HEAD becomes the baseline, so the pending source coverage has been permanently dropped unless another relevant change triggers it.

I reproduced this with the actual helper against a local API server, these workflow commands, and the current routing script. The normal API response selected all six nightly outputs. HTTP 503 returned exit 0 and selected none for the same Git history. This is the remaining error-path gap in the last-green-run update.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and thanks for the reproduction — that is exactly the failure. Fixed in 01d000f by taking your first option: when there is no base the run can trust, it lists the whole tree and runs the entire nightly tier. The time window is gone rather than widened, because any guessed base has the same shape of bug at some history, and a run that covered everything is a base tomorrow can rely on.

I ran your scenario against the previous revision to confirm it, then against the fix, using the real helper, a local API server and the bash from ci.yml:

=== BEFORE (time-window fallback), API down ===
::warning::could not list previous scheduled runs (HTTP Error 503: Service Unavailable)
  Nightly base: uncovered source change
  selected:

=== AFTER (whole-tree fallback), API down ===
  Nightly base unknown; running the whole nightly tier
  selected: build_linux_all_profiles spark_3_5 spark_4_0 iceberg_1_8 iceberg_1_9 iceberg_1_10

The other three paths still behave: a successful lookup diffs against it and selects the same six, a lookup that returns HEAD (a quiet day) selects nothing, and a base that has left main takes the fallback. Note that the whole-tree list goes through FILTERS and POLICY unchanged, so it can only ever reach the nightly tier — nothing in the queue tier comes back.

Since the thing that went wrong here is silent, I also added check_nightly_base_fallback to check-ci-config.py. It pins the schedule case to the nightly-base.py lookup and the whole-tree fallback, and rejects a base derived from the clock (--before/--since/--after/--until) with your scenario as the error message. Mutation-tested three ways: restoring the time window, replacing the lookup with a fixed revision, and renaming the case all fail the check.

The scheduled run's base is the commit the last successful scheduled run
tested. When that lookup yields nothing -- the first nightly, an Actions
API error, or a base no longer on main -- it fell back to the tip as of
24 hours ago. That window can silently drop coverage: if the last green
nightly was three nights ago and an uncovered source change landed two
nights ago, a one-day window starts after that change, so its suites are
skipped, the run goes green, and the green head becomes tomorrow's base.

Fall back to listing the whole tree instead, the same way the first push
to a branch does, so POLICY selects the entire nightly tier and a green
run is a base that can be trusted. check-ci-config.py pins the shape of
the schedule case, since the failure it prevents is silent.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 01d000fc against a8e8157e, following review 5212699804. The nightly-coverage P2 is fixed. An unknown or non-ancestor baseline now selects the entire nightly tier instead of guessing a time window. No new or remaining P1/P2 findings.

The same exact helper/workflow reproduction now selects all six nightly outputs after HTTP 503, where the previous revision selected none for the uncovered-source history. Ten baseline cases, event/label/profile checks, four negative checker mutations, and both failure-report JavaScript stub cases passed. The five original profile definitions remain unchanged. These are local component checks, not a hosted scheduled run or issue-publication test.

Preflight, including actionlint, passed. Required Checks failed on its cancelled Linux dependency, so overall CI remains incomplete. The tested merge preview c61a39cf used base 4479e722, not the assigned a8e8157e, but all changed workflow files match the reviewed head. No complete product-test pass or measured runner-time savings are claimed.

# Conflicts:
#	.github/workflows/README.md
#	.github/workflows/ci.yml
#	.github/workflows/pr_build_linux.yml
#	dev/ci/check-ci-config.py
#	dev/ci/compute-changes.py
#	dev/ci/linux-test-profiles.py
#	docs/source/contributor-guide/ci.md
#	docs/source/contributor-guide/development.md
#	docs/source/contributor-guide/iceberg-spark-tests.md
#	docs/source/contributor-guide/spark-sql-tests.md
@andygrove
andygrove enabled auto-merge September 15, 2026 19:45

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 11b08035 against 347d8cf3, following review 5214150540. No new or remaining P1/P2 findings. The merge of main preserves all 11 authored workflow, routing, and documentation files byte for byte from the approved revision. PR #5939 is now merged into this base, and the nightly-coverage fallback remains fixed.

The CI configuration check and 28 event/label/input checks passed locally. The five profile definitions remain unchanged and split into one default and four nightly profiles. The prior ten-case helper/workflow reproduction still applies to identical source. It was not rerun. Current Preflight, including actionlint, passed on merge preview 431ca295, whose parents are this exact base/head and whose full tree matches the reviewed head. Native build and Rust checks passed, and the completed shuffle job verified the native artifact's digest. Overall CI is still running. A hosted scheduled run, failure-issue publication, and runner-time savings remain unvalidated.

@andygrove
andygrove added this pull request to the merge queue Sep 15, 2026
Merged via the queue into apache:main with commit c074e0c Sep 15, 2026
37 checks passed
andygrove added a commit to andygrove/datafusion-comet that referenced this pull request Sep 16, 2026
Four conflicts, all in the CI tiering that landed on main while this branch
was out (apache#5926, apache#5939, apache#5963):

- dev/ci/compute-changes.py: keep both the spark_4_2 FILTERS entry and main's
  spark_4_1_hive entry, and give spark_4_2 the POLICY entry it never had.
  compute() iterates FILTERS and calls event_allows(), which indexes POLICY,
  so a FILTERS key with no POLICY entry made the changes job raise
  KeyError: 'spark_4_2' on every event that is not a dispatch. The 4.2 filter
  list also picks up the inputs spark_4_1 gained since (spark-sql-modules.py,
  the artifact retry actions, .mvn and mvnw) so the shared-build routing cases
  cover it.
- .github/workflows/ci.yml: expose both new outputs; take main's Detect
  changes script, whose schedule case replaces the dispatch key list; keep
  main's docs if: but keep this branch's permissions: contents: write, which
  the new top-level contents: read default makes load-bearing for the asf-site
  deploy. The spark_4_2 job's event check moves into POLICY, and the job joins
  required_checks.needs so it cannot fail without blocking the queue.
- dev/ci/check-ci-config.py: declare spark_4_2 in BUILD_JOBS, in a
  SPARK_EXPERIMENTAL tier set feeding ALL_JOBS, and in POLICY_CASES.
- .github/workflows/README.md: take main's tables and add the 4.2 rows.

Spark 4.2 keeps its on demand only routing, now expressed as
label:run-spark-4.2-tests (the label already exists on the repo) rather than
an event check in ci.yml. adding_a_new_spark_version.md described the old
dispatch gating, a key list main deleted, and a preflight label allowlist that
no longer exists, so its CI section is rewritten to match.

check-ci-config.py, actionlint and prettier all pass.
andygrove added a commit that referenced this pull request Sep 16, 2026
An actions/cache entry is scoped to the ref that wrote it: a run restores
from its own ref and from the default branch, and nothing else. So an entry
written from refs/pull/*/merge is visible only to another run of the same
pull request, and one written from the merge queue's gh-readonly-queue/*
branch is visible to nobody, because the queue deletes that branch when it
is finished with it. Both still consume the repository's shared cache
budget, which is evicted least-recently-used.

On 2026-09-15 the repository held 12.27 GiB across 14 entries: 9.22 GiB on
one gh-readonly-queue/* branch, 3.01 GiB on refs/pull/*/merge, and nothing
at all on main. Seven near-identical Linux-java-maven-* Maven repositories
accounted for 9.06 GiB of it, every one of them a write-only copy. With
main's cargo-ci entry evicted, all eight native builds in merge_group run
34977717057 missed their cache and paid a cold compile of 20 to 28 minutes
each, 259 runner-minutes in a single run. The same build on the push run two
hours later restored in 56s and compiled in 2m21s.

The two cargo saves in pr_build_linux.yml carried `if: github.ref ==
'refs/heads/main'` when this was written. #5963 has since replaced that guard
with `if: github.event_name == 'push'`, which also excludes the scheduled run
at the same sha, so both conditions are now spelled out together and
check_cache_save_scope can see the ref half. Hold every other large cache to
the same rule: a cache of ~/.m2/repository or of a cargo tree is restored
everywhere and saved only on push to main. The bare actions/cache@vN form
cannot express that, since it saves in an implicit post step no `if:` can
reach, so those sites split into actions/cache/restore plus a guarded
actions/cache/save.

The TPC-H and TPC-DS datasets keep the read-write form: they are a few
hundred MB, they are keyed on the workflow file, and a pull request editing
that file would otherwise regenerate them on every run. publish_snapshot.yml
keeps it too, since it runs from main on a schedule already.

check_cache_save_scope in dev/ci/check-ci-config.py pins both halves, and
rejects a new bare actions/cache@vN on any of those paths. Six mutations
were used to confirm it fails: reverting a site to the read-write form,
dropping the github.ref guard from each of four saves, and adding a fresh
unguarded cargo cache.

pyarrow_udf_test.yml now lists itself in its paths filter. It sits outside
the ci.yml umbrella, so compute-changes.py does not route it, and an edit to
the file merged without the workflow ever running -- including the cache
step changed here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Iceberg build Build environment enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants