Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion ddpui/api/orgtask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
from ddpui.utils import timezone
from ddpui.utils.constants import (
TASK_GITPULL,
TASK_DBTCLEAN,
TASK_DBTDEPS,
TRANSFORM_TASKS_SEQ,
TASK_GENERATE_EDR,
LONG_RUNNING_TASKS,
Expand Down Expand Up @@ -227,7 +229,10 @@ def get_elemetary_task_lock(request):
@orgtask_router.get("transform/")
@has_permission(["can_view_orgtasks"])
def get_prefect_transformation_tasks(request, exclude_git: bool = False):
"""Fetch all dbt tasks for an org; client or system"""
"""Fetch all dbt tasks for an org; client or system.
When exclude_git=True (used by pipeline page), auto-managed tasks
(git, dbt-clean, dbt-deps) are excluded since they are automatically
added during pipeline creation/updation."""
orguser: OrgUser = request.orguser

task_types = [TaskType.DBT, TaskType.DBTCLOUD]
Expand All @@ -252,10 +257,15 @@ def get_prefect_transformation_tasks(request, exclude_git: bool = False):

res = []

auto_managed_task_slugs = {TASK_DBTCLEAN, TASK_DBTDEPS}

for org_task in org_tasks:
if org_task.task.slug not in TRANSFORM_TASKS_SEQ:
continue

if exclude_git and org_task.task.slug in auto_managed_task_slugs:
continue

# git pull : "git" + " " + "pull"
# dbt run --full-refresh : "dbt" + " " + "run --full-refresh"
command = None
Expand Down
71 changes: 65 additions & 6 deletions ddpui/core/orchestrate/pipeline_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from ddpui.utils.constants import (
TASK_AIRBYTESYNC,
TASK_DBTRUN,
TASK_DBTCLEAN,
TASK_DBTDEPS,
TASK_GITPULL,
TASK_AIRBYTECLEAR,
TASK_GITCLONE,
Expand Down Expand Up @@ -101,6 +103,10 @@ def _build_transform_tasks(
dbt_orgtasks = []
git_orgtasks = []
dbt_cloud_orgtasks = []
auto_managed_dbt_orgtasks = []

# Task slugs that are auto-managed and should not come from frontend
auto_managed_task_slugs = {TASK_DBTCLEAN, TASK_DBTDEPS}

transform_tasks_payload.sort(key=lambda task: task.seq)

Expand All @@ -111,21 +117,27 @@ def _build_transform_tasks(
f"transform task with uuid {transform_task.uuid} not found"
)

if org_task.task.type == TaskType.DBT:
dbt_orgtasks.append(org_task)
elif org_task.task.type == TaskType.GIT:
if org_task.task.type == TaskType.GIT:
# Skip git tasks - they should not come from frontend anymore
logger.warning(
f"Ignoring git task {org_task.task.slug} from frontend - git tasks are auto-managed"
)
continue
elif (
org_task.task.type == TaskType.DBT and org_task.task.slug in auto_managed_task_slugs
):
# Skip dbt-clean and dbt-deps - they are auto-managed
logger.warning(f"Ignoring {org_task.task.slug} from frontend - auto-managed")
continue
elif org_task.task.type == TaskType.DBT:
dbt_orgtasks.append(org_task)
elif org_task.task.type == TaskType.DBTCLOUD:
dbt_cloud_orgtasks.append(org_task)

logger.info(f"{len(dbt_orgtasks)} DBT cli tasks being pushed to the pipeline")
logger.info(f"{len(dbt_cloud_orgtasks)} Dbt cloud tasks being pushed to the pipeline")

# Add git step automatically based on workpool type
# Auto-add git and dbt-clean/dbt-deps steps when there are DBT tasks
if len(dbt_orgtasks) > 0:
if PipelineService._is_workpool_eks(org):
logger.info("EKS workpool detected, adding git clone step before DBT tasks")
Expand All @@ -136,6 +148,12 @@ def _build_transform_tasks(
git_pull_orgtask = PipelineService._get_or_create_git_pull_orgtask(org)
git_orgtasks.insert(0, git_pull_orgtask)

# Auto-add dbt clean and dbt deps before other DBT tasks
logger.info("Adding dbt clean and dbt deps steps before DBT tasks")
dbt_clean_orgtask = PipelineService._get_or_create_dbt_clean_orgtask(org)
dbt_deps_orgtask = PipelineService._get_or_create_dbt_deps_orgtask(org)
auto_managed_dbt_orgtasks = [dbt_clean_orgtask, dbt_deps_orgtask]

# dbt cli profile block - only needed if we have DBT tasks
cli_block = None
if len(dbt_orgtasks) > 0:
Expand All @@ -151,9 +169,10 @@ def _build_transform_tasks(
raise PipelineConfigurationError("dbt cloud creds block not found")

# get the deployment task configs
all_orgtasks = git_orgtasks + auto_managed_dbt_orgtasks + dbt_orgtasks + dbt_cloud_orgtasks
task_configs, error = pipeline_with_orgtasks(
org,
git_orgtasks + dbt_orgtasks + dbt_cloud_orgtasks,
all_orgtasks,
cli_block=cli_block,
dbt_project_params=dbt_project_params,
start_seq=len(existing_task_configs),
Expand All @@ -163,7 +182,7 @@ def _build_transform_tasks(
if error:
raise PipelineConfigurationError(error)

map_org_tasks = git_orgtasks + dbt_orgtasks + dbt_cloud_orgtasks
map_org_tasks = all_orgtasks
return task_configs, map_org_tasks

@staticmethod
Expand Down Expand Up @@ -734,3 +753,43 @@ def _get_or_create_git_pull_orgtask(org: Org) -> OrgTask:
logger.info(f"Created git pull OrgTask for org {org.slug}")

return git_pull_orgtask

@staticmethod
def _get_or_create_dbt_clean_orgtask(org: Org) -> OrgTask:
"""Get or create dbt clean OrgTask for the organization"""
dbt_clean_task = Task.objects.filter(slug=TASK_DBTCLEAN).first()
if not dbt_clean_task:
raise PipelineConfigurationError("dbt-clean task not found in database")

orgdbt = org.dbt
if not orgdbt:
raise PipelineConfigurationError("dbt configuration not found for organization")

dbt_clean_orgtask, created = OrgTask.objects.get_or_create(
org=org, task=dbt_clean_task, dbt=orgdbt, defaults={"parameters": {}}
)

if created:
logger.info(f"Created dbt clean OrgTask for org {org.slug}")

return dbt_clean_orgtask

@staticmethod
def _get_or_create_dbt_deps_orgtask(org: Org) -> OrgTask:
"""Get or create dbt deps OrgTask for the organization"""
dbt_deps_task = Task.objects.filter(slug=TASK_DBTDEPS).first()
if not dbt_deps_task:
raise PipelineConfigurationError("dbt-deps task not found in database")

orgdbt = org.dbt
if not orgdbt:
raise PipelineConfigurationError("dbt configuration not found for organization")

dbt_deps_orgtask, created = OrgTask.objects.get_or_create(
org=org, task=dbt_deps_task, dbt=orgdbt, defaults={"parameters": {}}
)

if created:
logger.info(f"Created dbt deps OrgTask for org {org.slug}")

return dbt_deps_orgtask
164 changes: 164 additions & 0 deletions ddpui/management/commands/backfill_auto_managed_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""
Management command to backfill auto-managed tasks (git pull/clone, dbt clean, dbt deps)
in all existing pipelines.

For each orchestrate pipeline that has transform tasks, this command will
re-run update_pipeline which automatically adds the missing auto-managed steps
based on the org's workpool configuration.
"""

from django.core.management.base import BaseCommand
from ddpui.models.org import Org, OrgDataFlowv1
from ddpui.models.tasks import DataflowOrgTask, TaskType
from ddpui.ddpprefect.schema import PrefectDataFlowUpdateSchema3
from ddpui.ddpprefect import prefect_service
from ddpui.utils.constants import TASK_DBTCLEAN, TASK_DBTDEPS
from ddpui.utils.unified_logger import get_logger
from ddpui.core.orchestrate.pipeline_service import PipelineService

logger = get_logger()


class Command(BaseCommand):
help = "Backfill auto-managed tasks (git pull/clone, dbt clean, dbt deps) in all existing pipelines"

def add_arguments(self, parser):
parser.add_argument(
"--org-slug",
type=str,
required=False,
help="Only backfill for a specific organization (optional)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without making actual changes",
)

def handle(self, *args, **options):
org_slug = options.get("org_slug")
dry_run = options["dry_run"]

if org_slug:
orgs = Org.objects.filter(slug=org_slug)
if not orgs.exists():
self.stdout.write(self.style.ERROR(f"Organization '{org_slug}' not found"))
return
else:
orgs = Org.objects.all()

total_updated = 0
total_skipped = 0
total_errors = 0

for org in orgs:
updated, skipped, errors = self.process_org(org, dry_run)
total_updated += updated
total_skipped += skipped
total_errors += errors

self.stdout.write(f"\n{'[DRY RUN] ' if dry_run else ''}Summary:")
self.stdout.write(f" Pipelines updated: {total_updated}")
self.stdout.write(f" Pipelines skipped (no transform tasks): {total_skipped}")
self.stdout.write(f" Errors: {total_errors}")

def process_org(self, org: Org, dry_run: bool):
"""Process all orchestrate pipelines for an organization"""
dataflows = OrgDataFlowv1.objects.filter(org=org, dataflow_type="orchestrate")

if not dataflows.exists():
return 0, 0, 0

self.stdout.write(f"\nOrg: {org.slug} ({org.name})")

updated = 0
skipped = 0
errors = 0

for dataflow in dataflows:
# Check if this pipeline has transform tasks
has_transform = DataflowOrgTask.objects.filter(
dataflow=dataflow,
orgtask__task__type=TaskType.DBT,
).exists()

if not has_transform:
self.stdout.write(f" → Skipping {dataflow.deployment_name} (no transform tasks)")
skipped += 1
continue

# Check if dbt-clean and dbt-deps are already present
has_dbt_clean = DataflowOrgTask.objects.filter(
dataflow=dataflow, orgtask__task__slug=TASK_DBTCLEAN
).exists()
has_dbt_deps = DataflowOrgTask.objects.filter(
dataflow=dataflow, orgtask__task__slug=TASK_DBTDEPS
).exists()

if has_dbt_clean and has_dbt_deps:
self.stdout.write(
f" → Skipping {dataflow.deployment_name} (already has dbt-clean and dbt-deps)"
)
skipped += 1
continue
Comment on lines +90 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This skip condition misses pipelines that only lack the auto-managed git step.

A legacy CLI DBT pipeline that already has dbt-clean/dbt-deps but is missing its git pull/clone mapping will be skipped here, even though PipelineService.update_pipeline() would add that missing step. That leaves part of the fleet unbackfilled.

Suggested fix
             has_dbt_clean = DataflowOrgTask.objects.filter(
                 dataflow=dataflow, orgtask__task__slug=TASK_DBTCLEAN
             ).exists()
             has_dbt_deps = DataflowOrgTask.objects.filter(
                 dataflow=dataflow, orgtask__task__slug=TASK_DBTDEPS
             ).exists()
+            has_git_task = DataflowOrgTask.objects.filter(
+                dataflow=dataflow, orgtask__task__type=TaskType.GIT
+            ).exists()

-            if has_dbt_clean and has_dbt_deps:
+            if has_git_task and has_dbt_clean and has_dbt_deps:
                 self.stdout.write(
-                    f"  → Skipping {dataflow.deployment_name} (already has dbt-clean and dbt-deps)"
+                    f"  → Skipping {dataflow.deployment_name} "
+                    f"(already has git + dbt-clean + dbt-deps)"
                 )
                 skipped += 1
                 continue

             missing = []
+            if not has_git_task:
+                missing.append("git")
             if not has_dbt_clean:
                 missing.append("dbt-clean")
             if not has_dbt_deps:
                 missing.append("dbt-deps")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ddpui/management/commands/backfill_auto_managed_tasks.py` around lines 90 -
103, The current skip logic checks only for TASK_DBTCLEAN and TASK_DBTDEPS using
DataflowOrgTask and will skip pipelines that still lack the auto-managed git
step; update the condition so we only skip when dbt-clean, dbt-deps AND the
auto-managed git mapping are present. Concretely, augment the exists check
(DataflowOrgTask.objects.filter(...).exists()) to also verify the git/orgtask
mapping (or the relevant git task slug) is present, or alternatively remove the
early continue and always call PipelineService.update_pipeline(dataflow, ...)
for pipelines that have dbt-clean and/or dbt-deps so update_pipeline can add the
missing git step; reference DataflowOrgTask, TASK_DBTCLEAN, TASK_DBTDEPS and
PipelineService.update_pipeline when making the change.


missing = []
if not has_dbt_clean:
missing.append("dbt-clean")
if not has_dbt_deps:
missing.append("dbt-deps")

if dry_run:
self.stdout.write(
f" [DRY RUN] Would update {dataflow.deployment_name} "
f"(missing: {', '.join(missing)})"
)
updated += 1
continue

try:
self.update_pipeline(org, dataflow)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Updated {dataflow.deployment_name} (added: {', '.join(missing)})"
)
)
updated += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ✗ Failed to update {dataflow.deployment_name}: {str(e)}")
)
logger.error(
f"Failed to backfill auto-managed tasks for {dataflow.deployment_name}: {str(e)}"
)
errors += 1

return updated, skipped, errors

def update_pipeline(self, org: Org, dataflow: OrgDataFlowv1):
"""Re-run update_pipeline to backfill auto-managed tasks"""
pipeline_details = PipelineService.get_pipeline_details(org, dataflow.deployment_id)

transform_tasks = pipeline_details.get("transformTasks", [])

# Convert UUIDs to strings for Pydantic validation
transform_tasks_str = [
{"uuid": str(task["uuid"]), "seq": task["seq"]} for task in transform_tasks
]

update_payload = PrefectDataFlowUpdateSchema3(
name=pipeline_details["name"],
cron=pipeline_details["cron"],
connections=pipeline_details["connections"],
transformTasks=transform_tasks_str,
)

PipelineService.update_pipeline(org, dataflow.deployment_id, update_payload)

# Toggle schedule inactive → active to clear pre-scheduled runs.
# Prefect schedules runs 1-2 days in advance; those won't pick up the
# updated deployment params unless the schedule is reset.
# Only do this for pipelines that have an active schedule.
if dataflow.cron and pipeline_details.get("isScheduleActive", False):
PipelineService.set_pipeline_schedule(org, dataflow.deployment_id, "inactive")
PipelineService.set_pipeline_schedule(org, dataflow.deployment_id, "active")
Comment on lines +158 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

A failure between inactive and active leaves the pipeline paused.

update_pipeline() has already succeeded by this point. If the second set_pipeline_schedule(..., "active") call errors, the command reports a failure but the schedule stays disabled. This needs a best-effort restore path so a partial backfill does not silently turn off production schedules.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ddpui/management/commands/backfill_auto_managed_tasks.py` around lines 158 -
164, The current sequence toggles the Prefect schedule inactive→active via
PipelineService.set_pipeline_schedule(org, dataflow.deployment_id, "inactive")
and then "active" which can leave the pipeline paused if the second call fails;
modify the block so that after making the schedule inactive you attempt to set
it back to "active" inside a guarded retry/restore path (try/except/finally):
call PipelineService.set_pipeline_schedule(...) to reactivate and on any
exception immediately log the error with context (include dataflow.deployment_id
and org), perform a best-effort retry or a compensating call to re-enable the
schedule (e.g., another set_pipeline_schedule(..., "active") attempt) and
surface failure but avoid leaving the schedule disabled; ensure this logic is
colocated with the existing update_pipeline() success flow so update_pipeline()
remains committed and schedules are restored on failure.

18 changes: 15 additions & 3 deletions ddpui/management/commands/migrate_org_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,12 +268,13 @@ def perform_migration(

for dataflow in dataflows:
try:
# Update queue/workpool for all dataflows first
self.update_dataflow_queue(dataflow, new_queue, final_workpool)

# For scheduled pipelines, update pipeline which will handle git steps automatically
# This runs after queue update so the schedule toggle picks up the new queue
if queue_type == "scheduled_pipeline_queue":
self.update_scheduled_pipeline(dataflow)

# Update queue/workpool for all dataflows
self.update_dataflow_queue(dataflow, new_queue, final_workpool)
self.stdout.write(f" ✓ Updated dataflow: {dataflow.deployment_name}")
updated_count += 1
except Exception as e:
Expand Down Expand Up @@ -363,6 +364,17 @@ def update_scheduled_pipeline(self, dataflow: OrgDataFlowv1):
# Update pipeline using PipelineService (handles git steps based on workpool)
PipelineService.update_pipeline(dataflow.org, dataflow.deployment_id, update_payload)

# Toggle schedule inactive → active to clear pre-scheduled runs.
# Prefect schedules runs 1-2 days in advance; those won't pick up
# the updated deployment params unless the schedule is reset.
if dataflow.cron and pipeline_details.get("isScheduleActive", False):
PipelineService.set_pipeline_schedule(
dataflow.org, dataflow.deployment_id, "inactive"
)
PipelineService.set_pipeline_schedule(
dataflow.org, dataflow.deployment_id, "active"
)

logger.info(f"Updated scheduled pipeline {dataflow.deployment_name}")

except Exception as e:
Expand Down
Loading
Loading