Skip to content

Commit 416f46d

Browse files
committed
feat(airflow): scaffold Phase-1 Airflow 3.x orchestration tree
Add a parse-only scaffold for migrating the U19 orchestration layer to Apache Airflow 3.x (assessment + plan in #95). - airflow/dags: nightly_populate (unified Python+MATLAB nightly + alerts), ephys_processing, imaging_processing — TaskFlow DAGs with stubbed task bodies. nightly_populate encodes the FK ordering plus the implicit edges found in the make() audit (TowersSessionPsychTask after TowersBlock; a barrier before the subject-cumulative psych tables). - airflow/plugins/u19: stub hooks/helpers that wrap existing code (slurm_creator, clusters_paths_and_transfers, slack_utils, dual-write of recording_process status, matlab -batch). No logic reimplemented. - CI: .github/workflows/airflow-dag-validation.yml runs a DagBag import-error check via `uv sync --only-group airflow` + `uv run`. - pyproject.toml: add locked `airflow` dependency group (apache-airflow 3.2.2 + ssh/standard providers); uv.lock updated. All three DAGs parse with zero import errors against Airflow 3.2.2. Task bodies are TODO stubs; deployment/infra questions tracked in #95. Assisted-by: ClaudeCode:claude-opus-4.8
1 parent f363515 commit 416f46d

15 files changed

Lines changed: 2987 additions & 17 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Airflow DAG validation workflow
2+
# Runs on every push and pull_request to verify that all DAGs in airflow/dags/
3+
# are importable by Airflow's DagBag with zero import errors.
4+
# No Airflow database is required — DagBag loads the DAGs in-process.
5+
#
6+
# Dependencies are managed with uv against the locked `airflow` dependency group
7+
# in pyproject.toml (uv.lock), so installs are reproducible without a pip
8+
# constraints file.
9+
10+
name: Airflow DAG Validation
11+
12+
on:
13+
push:
14+
pull_request:
15+
16+
jobs:
17+
dag-validation:
18+
name: Validate DAGs (Python 3.12 / Airflow 3.2.2)
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v4
24+
25+
- name: Install uv
26+
uses: astral-sh/setup-uv@v5
27+
with:
28+
enable-cache: true
29+
30+
# Resolve only the `airflow` dependency group from uv.lock — this keeps the
31+
# CI environment small (no GPU/science stack) and fully reproducible.
32+
- name: Sync Airflow dependencies
33+
run: uv sync --only-group airflow
34+
35+
# Run DagBag import-error check via `uv run`.
36+
# AIRFLOW__CORE__DAGS_FOLDER points at the DAG directory.
37+
# PYTHONPATH includes airflow/plugins so the u19.* plugin imports resolve.
38+
# AIRFLOW__CORE__LOAD_EXAMPLES=False avoids loading Airflow's bundled examples.
39+
- name: Check DAG import errors
40+
env:
41+
AIRFLOW__CORE__DAGS_FOLDER: "${{ github.workspace }}/airflow/dags"
42+
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
43+
PYTHONPATH: "${{ github.workspace }}/airflow/plugins"
44+
run: |
45+
uv run --only-group airflow python - <<'EOF'
46+
from airflow.models import DagBag
47+
import os
48+
49+
dags_folder = os.environ["AIRFLOW__CORE__DAGS_FOLDER"]
50+
db = DagBag(dag_folder=dags_folder, include_examples=False)
51+
52+
if db.import_errors:
53+
import json
54+
print("DAG import errors detected:")
55+
print(json.dumps(db.import_errors, indent=2))
56+
raise SystemExit(1)
57+
58+
print(f"OK — {len(db.dags)} DAG(s) loaded with no import errors:")
59+
for dag_id in sorted(db.dags):
60+
print(f" {dag_id}")
61+
EOF

airflow/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# U19 Pipeline — Airflow Phase-1 Scaffold
2+
3+
This directory contains the **Phase-1 Apache Airflow scaffold** for migrating the U19
4+
neuroscience pipeline from bare cron jobs to Airflow orchestration. The project targets
5+
**Airflow 3.x** (currently pinned to `apache-airflow==3.2.2`). Tracked in
6+
[BrainCOGS/U19-pipeline-python#95](https://github.com/BrainCOGS/U19-pipeline-python/issues/95).
7+
8+
## Layout
9+
10+
```
11+
airflow/
12+
README.md # this file
13+
requirements-airflow.txt # Airflow + provider pins
14+
dags/
15+
nightly_populate.py # @daily — behavior/optogenetics/pupillometry/alerts
16+
ephys_processing.py # @hourly — ephys recording pipeline
17+
imaging_processing.py # @hourly — imaging recording pipeline
18+
plugins/u19/
19+
__init__.py
20+
datajoint_sensor.py # DataJoint table-populated sensor
21+
slurm.py # SLURM job submission/polling
22+
transfers.py # Globus file transfer helpers
23+
matlab.py # SSH-based MATLAB batch runner
24+
status.py # Dual-write status + log helper
25+
callbacks.py # Slack failure callback
26+
```
27+
28+
## Status
29+
30+
**All task bodies are stubs** (`NotImplementedError` / `pass`). The DAGs parse and are
31+
DagBag-importable, but no real work is performed. Each stub references the existing
32+
`u19_pipeline` module that should be called once the task is fleshed out.
33+
34+
## DAG Validation in CI
35+
36+
A GitHub Actions workflow (`.github/workflows/airflow-dag-validation.yml`) uses **uv**
37+
to `uv sync --only-group airflow` (Airflow 3.2.2, locked in `uv.lock`) and runs a
38+
`DagBag` import-error check on every push and pull request. No Airflow database is
39+
required — the DAGs are loaded in-process. The `airflow` dependency group is declared in
40+
the project `pyproject.toml`.
41+
42+
## Open Infrastructure Questions
43+
44+
The following decisions are **not yet made** and block a production deployment:
45+
46+
1. **PNI hosting** — where does the Airflow webserver/scheduler run? (dedicated VM,
47+
existing `braincogs` server, or cloud?)
48+
2. **MATLAB host** — which host runs the MATLAB batch processes, and what SSH
49+
`conn_id` ("matlab_host") maps to in the Airflow Connections table?
50+
3. **AcquiredTiff owner**`imaging_processing.py` notes the `AcquiredTiff` node is
51+
contended. Should it be owned by a Python operator or a MATLAB SSH call? Guards
52+
against double-run are needed before this DAG goes live.
53+
4. **Metadata DB** — Airflow's metadata DB (Postgres recommended for production) needs
54+
to be provisioned and the connection string set before scheduler startup.

airflow/dags/ephys_processing.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# NOTE: scaffold — bodies are stubs
2+
"""Ephys recording processing DAG for U19.
3+
4+
Runs @hourly. Polls for active processing jobs and drives each through the
5+
linear pipeline: raw transfer → SLURM preprocessing → processed transfer →
6+
DataJoint element populate.
7+
8+
All task bodies are TODO stubs. Dynamic mapping is stubbed with a comment
9+
placeholder — a linear task graph is wired instead so the DAG parses cleanly.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
from datetime import datetime, timedelta
16+
17+
from airflow.sdk import dag, task
18+
19+
log = logging.getLogger(__name__)
20+
21+
# NOTE: u19 plugin imports are inside task bodies (TODO stubs) so the DAG
22+
# parses even when plugins/ is not on PYTHONPATH at collection time.
23+
24+
default_args = {
25+
"owner": "u19",
26+
"retries": 1,
27+
"retry_delay": timedelta(minutes=10),
28+
}
29+
30+
31+
@dag(
32+
dag_id="u19_ephys_processing",
33+
schedule="@hourly",
34+
start_date=datetime(2024, 1, 1),
35+
catchup=False,
36+
default_args=default_args,
37+
tags=["u19", "ephys", "processing"],
38+
)
39+
def u19_ephys_processing() -> None:
40+
"""Ephys processing DAG.
41+
42+
Intended flow (one task-group per active job)::
43+
44+
request_raw_transfer
45+
→ wait_raw_transfer
46+
→ submit_slurm
47+
→ wait_slurm
48+
→ request_proc_transfer
49+
→ wait_proc_transfer
50+
→ populate_element
51+
52+
Each step calls the matching plugin and calls dual_write_status after
53+
advancing the job state.
54+
55+
TODO: replace the linear stub below with dynamic task-group mapping once
56+
``get_active_jobs()`` is implemented, e.g.::
57+
58+
@task_group
59+
def process_job(job_id: int) -> None:
60+
...
61+
62+
jobs = get_active_jobs()
63+
process_job.expand(job_id=jobs)
64+
"""
65+
66+
@task(task_id="get_active_jobs")
67+
def get_active_jobs() -> list[int]:
68+
"""Return list of job_ids that are ready for processing.
69+
70+
TODO: query recording_process.Processing for jobs in the
71+
'transfer_request' status; return their job_ids.
72+
73+
Returns
74+
-------
75+
list[int]
76+
List of ``recording_process.Processing.job_id`` values.
77+
"""
78+
# TODO: from u19_pipeline import recording_process
79+
# jobs = (recording_process.Processing & {"status_processing_id": STATUS_TRANSFER_REQUEST}).fetch("job_id")
80+
# return list(jobs)
81+
log.info("get_active_jobs stub — returning empty list")
82+
return []
83+
84+
@task(task_id="request_raw_transfer")
85+
def request_raw_transfer(job_ids: list[int]) -> None:
86+
"""Initiate Globus raw-data transfer from PNI to processing cluster.
87+
88+
Wraps u19.transfers.globus_transfer.
89+
Calls dual_write_status after initiating each transfer.
90+
91+
TODO: for job_id in job_ids:
92+
task_id = globus_transfer(pni_ep, tiger_ep, raw_src, raw_dst, job_id=job_id)
93+
dual_write_status({"job_id": job_id}, STATUS_RAW_TRANSFER_STARTED)
94+
"""
95+
# TODO: from u19.transfers import globus_transfer
96+
# from u19.status import dual_write_status
97+
pass
98+
99+
@task(task_id="wait_raw_transfer")
100+
def wait_raw_transfer(job_ids: list[int]) -> None:
101+
"""Poll until raw Globus transfer reaches SUCCEEDED state.
102+
103+
Wraps u19.transfers.check_transfer_status.
104+
Calls dual_write_status when transfer completes.
105+
106+
TODO: poll check_transfer_status(task_id) until SUCCEEDED/FAILED
107+
dual_write_status({"job_id": job_id}, STATUS_RAW_TRANSFER_DONE)
108+
"""
109+
# TODO: from u19.transfers import check_transfer_status
110+
# from u19.status import dual_write_status
111+
pass
112+
113+
@task(task_id="submit_slurm")
114+
def submit_slurm(job_ids: list[int]) -> None:
115+
"""Generate and submit a SLURM job for each active job.
116+
117+
Wraps u19.slurm.submit_slurm_job.
118+
Calls dual_write_status after submission.
119+
120+
TODO: for job_id in job_ids:
121+
slurm_job_id = submit_slurm_job(job_id, program_selection_params)
122+
dual_write_status({"job_id": job_id}, STATUS_SLURM_SUBMITTED)
123+
"""
124+
# TODO: from u19.slurm import submit_slurm_job
125+
# from u19.status import dual_write_status
126+
pass
127+
128+
@task(task_id="wait_slurm")
129+
def wait_slurm(job_ids: list[int]) -> None:
130+
"""Poll SLURM until the job reaches a terminal state.
131+
132+
Wraps u19.slurm.poll_slurm_job.
133+
Calls dual_write_status when SLURM job completes.
134+
135+
NOTE: should become a deferrable operator in Phase 2.
136+
137+
TODO: for job_id in job_ids:
138+
state = poll_slurm_job(slurm_job_id, job_id=job_id)
139+
dual_write_status({"job_id": job_id}, STATUS_SLURM_DONE if state=="COMPLETED" else STATUS_ERROR)
140+
"""
141+
# TODO: from u19.slurm import poll_slurm_job
142+
# from u19.status import dual_write_status
143+
pass
144+
145+
@task(task_id="request_proc_transfer")
146+
def request_proc_transfer(job_ids: list[int]) -> None:
147+
"""Initiate Globus processed-data transfer from cluster back to PNI.
148+
149+
Wraps u19.transfers.globus_transfer.
150+
Calls dual_write_status after initiating each transfer.
151+
152+
TODO: for job_id in job_ids:
153+
task_id = globus_transfer(tiger_ep, pni_ep, proc_src, proc_dst, job_id=job_id)
154+
dual_write_status({"job_id": job_id}, STATUS_PROC_TRANSFER_STARTED)
155+
"""
156+
# TODO: from u19.transfers import globus_transfer
157+
# from u19.status import dual_write_status
158+
pass
159+
160+
@task(task_id="wait_proc_transfer")
161+
def wait_proc_transfer(job_ids: list[int]) -> None:
162+
"""Poll until processed-data Globus transfer reaches SUCCEEDED state.
163+
164+
Wraps u19.transfers.check_transfer_status.
165+
Calls dual_write_status when transfer completes.
166+
167+
TODO: poll check_transfer_status(task_id) until SUCCEEDED/FAILED
168+
dual_write_status({"job_id": job_id}, STATUS_PROC_TRANSFER_DONE)
169+
"""
170+
# TODO: from u19.transfers import check_transfer_status
171+
# from u19.status import dual_write_status
172+
pass
173+
174+
@task(task_id="populate_element")
175+
def populate_element(job_ids: list[int]) -> None:
176+
"""Populate DataJoint ephys element tables for completed jobs.
177+
178+
Wraps u19_pipeline.automatic_job.ephys_element_populate.
179+
Calls dual_write_status after populate completes.
180+
181+
TODO: for job_id in job_ids:
182+
ephys_element_populate.run(job_id)
183+
dual_write_status({"job_id": job_id}, STATUS_COMPLETE)
184+
"""
185+
# TODO: import u19_pipeline.automatic_job.ephys_element_populate as ep
186+
# from u19.status import dual_write_status
187+
pass
188+
189+
# -------------------------------------------------------------------------
190+
# Wire linear stub graph
191+
# -------------------------------------------------------------------------
192+
t_jobs = get_active_jobs()
193+
t_req_raw = request_raw_transfer(t_jobs)
194+
t_wait_raw = wait_raw_transfer(t_req_raw)
195+
t_submit_slurm = submit_slurm(t_wait_raw)
196+
t_wait_slurm = wait_slurm(t_submit_slurm)
197+
t_req_proc = request_proc_transfer(t_wait_slurm)
198+
t_wait_proc = wait_proc_transfer(t_req_proc)
199+
populate_element(t_wait_proc)
200+
201+
202+
u19_ephys_processing()

0 commit comments

Comments
 (0)