Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b759023
UN-3843 [GATED-FEAT] PG queue — delayed-visibility primitive (countdo…
muhammad-ali-e Aug 5, 2026
9e406be
UN-3796 [GATED-FEAT] PG scheduler: fire non-pipeline Beat periodics (…
muhammad-ali-e Aug 5, 2026
c6f77e4
UN-3796 [GATED-FEAT] PG metrics execution path: worker-pg-metrics + i…
muhammad-ali-e Aug 5, 2026
6419dd1
UN-3796 [FIX] Bump PeriodicTasks.update_changed() on adopt/release or…
muhammad-ali-e Aug 5, 2026
81be693
UN-3796 [FIX] worker-pg-metrics compose command would crash-loop the …
muhammad-ali-e Aug 5, 2026
4c2d452
UN-3796 [FIX] Remove the org-chunking seam — it was a new feature, no…
muhammad-ali-e Aug 6, 2026
873bfa7
UN-3445 [GATED-FEAT] Refuse single-step execution on the PG transport
muhammad-ali-e Aug 6, 2026
2fa6272
UN-3755 [GATED-FEAT] Log streaming without Celery: Redis-list transport
muhammad-ali-e Aug 7, 2026
c7882e2
UN-3796 [GATED-FEAT] Declare the metrics periodics for the PG scheduler
muhammad-ali-e Aug 7, 2026
9dc9604
UN-3796 [GATED-FEAT] Mirror-only mode + automatic backfill on deploy …
muhammad-ali-e Aug 7, 2026
6352186
UN-3796 [GATED-FEAT] Implement the PG_SCHEDULER_ENABLED gate so Beat …
muhammad-ali-e Aug 7, 2026
5e759c2
UN-3755 [FIX] Forward LOG_TRANSPORT into the tool sidecar
muhammad-ali-e Aug 10, 2026
6397dd5
UN-3796 [FIX] Bare import in scheduler/tasks.py crash-looped worker-g…
muhammad-ali-e Aug 11, 2026
02fce44
UN-3755 [FIX] Log stream consumer socket timeout must outlive its BLM…
muhammad-ali-e Aug 11, 2026
a6ce3ef
UN-3796 [GATED-FEAT] Converge schedule ownership both ways so rollbac…
muhammad-ali-e Aug 14, 2026
6d41f12
Merge remote-tracking branch 'origin/main' into feat/UN-3445-pg-queue…
muhammad-ali-e Aug 14, 2026
0be13a7
UN-3796 [FIX] Import dashboard_metrics_tasks via the package so tests…
muhammad-ali-e Aug 14, 2026
ec0362f
UN-3796 [FIX] Baseline next_run_at on hand-over and resume, so neithe…
muhammad-ali-e Aug 14, 2026
d97e26d
UN-3796 [FIX] Terminalise executions created but never dispatched
muhammad-ali-e Aug 19, 2026
2088d69
UN-3796 [FIX] Baseline next_run_at on the UI resume path, not just en…
muhammad-ali-e Aug 20, 2026
229beab
UN-3796 [FIX] Only mirror an interval periodic when */N is exactly eq…
muhammad-ali-e Aug 20, 2026
1c05a35
UN-3796 [FIX] Baseline Beat's clock on release, or Beat replays every…
muhammad-ali-e Aug 24, 2026
eabb788
UN-3796 [FIX] Let the stuck-execution finalizer see Celery executions…
muhammad-ali-e Aug 24, 2026
4571e0e
UN-3796 [FIX] Default the stuck-recovery window to 10 min, not the ba…
muhammad-ali-e Aug 24, 2026
07bf352
UN-3796 [FIX] Make the stranded-execution sweep reach recoverable rows
muhammad-ali-e Aug 25, 2026
44b5807
Merge remote-tracking branch 'origin/main' into feat/UN-3445-pg-queue…
muhammad-ali-e Aug 25, 2026
bb77250
UN-3796 [FIX] Stop the undispatched sweep deleting a running executio…
muhammad-ali-e Aug 25, 2026
0c6b1d1
UN-3796 [FIX] The undispatched-sweep metric could never increment
muhammad-ali-e Aug 25, 2026
9e502f7
UN-3796 [FIX] Make the reorder guard real, and correct three comments…
muhammad-ali-e Aug 25, 2026
7f3cc38
UN-3796 [FIX] Collect runner/tests, pin available_at, and stop silent…
muhammad-ali-e Aug 25, 2026
6c658c0
UN-3796 [FIX] Remediation round 1: fix what the fixes broke
muhammad-ali-e Aug 25, 2026
1b7a26f
UN-3796 [FIX] Raise the undispatched-sweep grace to an hour — PG drop…
muhammad-ali-e Aug 25, 2026
02ecc60
UN-3796 [FIX] Pass modified_at explicitly — BaseModelManager.update()…
muhammad-ali-e Aug 26, 2026
1c4f1c7
UN-3796 [FIX] Record dispatch as a positive fact so the sweep stops g…
muhammad-ali-e Aug 26, 2026
a713fb6
UN-3796 [FIX] Match 0027's field to the model so makemigrations is clean
muhammad-ali-e Aug 26, 2026
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
7 changes: 7 additions & 0 deletions backend/backend/internal_base_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ def test_middleware_debug(request):
include("workflow_manager.workflow_execution_internal_urls"),
name="workflow_execution_internal",
),
# Dashboard-metrics periodics (UN-3796) — called by the thin worker tasks the PG
# scheduler fires, replacing Beat + workerMetrics.
path(
"v1/dashboard-metrics/",
include("dashboard_metrics.internal_urls"),
name="dashboard_metrics_internal",
),
# Workflow management and pipeline APIs
path(
"v1/workflow-manager/",
Expand Down
29 changes: 29 additions & 0 deletions backend/dashboard_metrics/internal_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Internal API URLs for the dashboard-metrics periodics (UN-3796).

Called by the thin worker tasks that the PG scheduler fires, replacing Beat +
``workerMetrics``. Mirrors the shape of ``execution_log_internal_urls``.
"""

from django.urls import path

from . import internal_views

app_name = "dashboard_metrics_internal"

urlpatterns = [
path(
"aggregate/",
internal_views.AggregateMetricsAPIView.as_view(),
name="aggregate_metrics",
),
path(
"cleanup/hourly/",
internal_views.CleanupHourlyMetricsAPIView.as_view(),
name="cleanup_hourly_metrics",
),
path(
"cleanup/daily/",
internal_views.CleanupDailyMetricsAPIView.as_view(),
name="cleanup_daily_metrics",
),
]
115 changes: 115 additions & 0 deletions backend/dashboard_metrics/internal_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Internal API for running the dashboard-metrics periodics (UN-3796).

Beat + ``workerMetrics`` are the last two Celery deployments blocking the
"scale every Celery deployment to zero" gate. The PG scheduler replaces Beat, but the
three ``dashboard_metrics.*`` tasks are **Django** — ORM, cache, a Redis lock — and the
PG consumer bootstraps its tasks from ``workers/`` with no Django, so it cannot run them
directly.

These endpoints are the execution half, following the pattern already used by
``ProcessLogHistoryAPIView`` / ``process_notification_buffer``: a thin worker-side task
POSTs here, and the backend runs the real function. The task bodies are plain functions
that happen to carry ``@shared_task``, so they are called **verbatim** — the aggregation
logic, its windows and its Redis lock are reused unchanged, not reimplemented.

Auth is entirely ``InternalAPIAuthMiddleware``, which fires on any ``/internal/`` path.

**Org context is deliberately absent.** The Celery path runs with no organization set —
which is why ``tasks.py`` uses ``_base_manager`` throughout — and the middleware
populates ``StateStore`` from ``X-Organization-ID``. The callers must not send that
header, and each view clears the slot defensively: ``StateStore`` is a thread-local and
gunicorn reuses threads, so a value left behind by an earlier request on the same thread
would silently scope these global aggregations to one tenant.
"""

import contextlib
import logging
from typing import Any

from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from utils.constants import Account
from utils.local_context import StateStore

from dashboard_metrics.tasks import (
aggregate_metrics_from_sources,
cleanup_daily_metrics,
cleanup_hourly_metrics,
)

logger = logging.getLogger(__name__)

# Mirrors the defaults the Beat rows carry in their kwargs
# (dashboard_metrics/migrations/0002_setup_periodic_tasks.py), so a caller that omits
# them gets the same retention the Celery path applies.
DEFAULT_HOURLY_RETENTION_DAYS = 30
DEFAULT_DAILY_RETENTION_DAYS = 365


def _clear_org_context() -> None:
"""Drop any organization left in this thread's StateStore. See the module docstring.

``suppress`` because ``StateStore.clear`` raises when the slot was never set, which
is the normal case and not an error.
"""
with contextlib.suppress(Exception):
StateStore.clear(Account.ORGANIZATION_ID)


def _int_arg(request: Request, key: str, default: int) -> int:
"""Read an optional positive integer from the request body."""
raw = request.data.get(key, default) if isinstance(request.data, dict) else default
try:
value = int(raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be an integer, got {raw!r}") from exc
if value < 1:
raise ValueError(f"{key} must be >= 1, got {value}")
return value


class _MetricsTaskAPIView(APIView):
"""Shared plumbing: clear org context, run, translate errors."""

def _run(self, fn, *args: Any, **kwargs: Any) -> Response:
_clear_org_context()
try:
return Response(fn(*args, **kwargs))
except ValueError as exc: # bad request body
return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
logger.error("dashboard-metrics internal call failed: %s", exc, exc_info=True)

Check failure on line 83 in backend/dashboard_metrics/internal_views.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaA3QcMq3ySR3UK6saXY&open=AaA3QcMq3ySR3UK6saXY&pullRequest=2254
return Response(
{"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)


class AggregateMetricsAPIView(_MetricsTaskAPIView):
"""Run the metrics aggregation.

Calls the Celery task body verbatim, Redis lock included — this endpoint exists
only because the PG consumer has no Django, not to change what the job does.
"""

def post(self, request: Request) -> Response:
return self._run(aggregate_metrics_from_sources)


class CleanupHourlyMetricsAPIView(_MetricsTaskAPIView):
def post(self, request: Request) -> Response:
try:
days = _int_arg(request, "retention_days", DEFAULT_HOURLY_RETENTION_DAYS)
except ValueError as exc:
return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return self._run(cleanup_hourly_metrics, retention_days=days)


class CleanupDailyMetricsAPIView(_MetricsTaskAPIView):
def post(self, request: Request) -> Response:
try:
days = _int_arg(request, "retention_days", DEFAULT_DAILY_RETENTION_DAYS)
except ValueError as exc:
return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return self._run(cleanup_daily_metrics, retention_days=days)
97 changes: 97 additions & 0 deletions backend/dashboard_metrics/migrations/0004_pg_periodic_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Declare the dashboard-metrics periodics for the PG scheduler (UN-3796).

The PG twin of ``0002_setup_periodic_tasks``, which declares the same three schedules
for Celery Beat. **Deliberately in this app rather than in ``pg_queue``**: the failure
mode that matters is the two declarations drifting apart, and a reviewer editing a
schedule sees both only if they sit side by side.

Why a data migration here, when the pipeline mirror is a management command: these are
three fixed rows known at build time, so a migration is the right tool — tiny, idempotent
(``update_or_create``), and it reaches every environment including on-prem with no
operator step. Pipeline schedules are bulk and per-environment, so they stay a chunked
command that runs outside the migrate transaction.

Rows land **inert**: ``pg_owned=False`` and ``next_run_at=NULL``. Nothing fires from this
migration — the PG scheduler skips rows it does not own, and a NULL ``next_run_at`` means
"record a baseline next tick" rather than "overdue, fire now", so enabling the flag never
produces a burst of catch-up runs.

``task_kwargs`` is stored **decoded**: Beat keeps ``kwargs`` as a JSON *string*
(``'{"retention_days": 30}'``) while ``PgPeriodicTask.task_kwargs`` is a JSONField, so the
dispatcher can build a payload without re-parsing per tick.
"""

from django.db import migrations

# Single source for both directions, and importable by the drift test. Mirrors the Beat
# declarations in 0002_setup_periodic_tasks one-for-one — same names (the mirror key is
# PeriodicTask.name), same queue, same kwargs, and cron strings equivalent to the
# IntervalSchedule/CrontabSchedule rows there.
PG_PERIODIC_TASKS = [
{
"name": "dashboard_metrics_aggregate_from_sources",
"task_name": "dashboard_metrics.aggregate_from_sources",
"queue": "dashboard_metric_events",
"task_args": [],
"task_kwargs": {},
# Beat: IntervalSchedule(every=15, period="minutes")
"cron_string": "*/15 * * * *",
},
{
"name": "dashboard_metrics_cleanup_hourly",
"task_name": "dashboard_metrics.cleanup_hourly_data",
"queue": "dashboard_metric_events",
"task_args": [],
"task_kwargs": {"retention_days": 30},
# Beat: CrontabSchedule(minute=0, hour=2, every day) UTC
"cron_string": "0 2 * * *",
},
{
"name": "dashboard_metrics_cleanup_daily",
"task_name": "dashboard_metrics.cleanup_daily_data",
"queue": "dashboard_metric_events",
"task_args": [],
"task_kwargs": {"retention_days": 365},
# Beat: CrontabSchedule(minute=0, hour=3, day_of_week=0 → Sunday) UTC
"cron_string": "0 3 * * 0",
},
]


def create_pg_periodic_tasks(apps, schema_editor):
PgPeriodicTask = apps.get_model("pg_queue", "PgPeriodicTask")

Check warning on line 62 in backend/dashboard_metrics/migrations/0004_pg_periodic_tasks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "PgPeriodicTask" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaA3QcMh3ySR3UK6saXW&open=AaA3QcMh3ySR3UK6saXW&pullRequest=2254
for spec in PG_PERIODIC_TASKS:
PgPeriodicTask.objects.update_or_create(
name=spec["name"],
defaults={
"task_name": spec["task_name"],
"queue": spec["queue"],
"task_args": spec["task_args"],
"task_kwargs": spec["task_kwargs"],
"cron_string": spec["cron_string"],
"org_id": "",
"enabled": True,
# Inert until the rollout flag decides otherwise; never fired by
# applying this migration.
"pg_owned": False,
},
)


def remove_pg_periodic_tasks(apps, schema_editor):
PgPeriodicTask = apps.get_model("pg_queue", "PgPeriodicTask")

Check warning on line 82 in backend/dashboard_metrics/migrations/0004_pg_periodic_tasks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "PgPeriodicTask" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaA3QcMh3ySR3UK6saXX&open=AaA3QcMh3ySR3UK6saXX&pullRequest=2254
PgPeriodicTask.objects.filter(
name__in=[spec["name"] for spec in PG_PERIODIC_TASKS]
).delete()


class Migration(migrations.Migration):
dependencies = [
("dashboard_metrics", "0003_alter_eventmetricsdaily_organization_and_more"),
# The table this seeds.
("pg_queue", "0003_pgperiodictask"),
]

operations = [
migrations.RunPython(create_pg_periodic_tasks, remove_pg_periodic_tasks),
]
113 changes: 113 additions & 0 deletions backend/dashboard_metrics/tests/test_pg_periodic_task_declarations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Drift guard between the Beat and PG declarations of the metrics periodics (UN-3796).

Two migrations declare the same three schedules — ``0002_setup_periodic_tasks`` for Celery
Beat and ``0004_pg_periodic_tasks`` for the PG scheduler. They are separate rows in
separate tables, so nothing stops someone editing one and forgetting the other. That is
the whole failure mode this file exists for: a schedule changed on Beat but not on PG means
the task silently runs on a different cadence the moment the flag flips.

DB-free — both migration modules are imported and their declared specs compared directly,
so this runs in the unit tier rather than needing a migrated database.
"""

from __future__ import annotations

import importlib
import json

import pytest

_BEAT_MIGRATION = "dashboard_metrics.migrations.0002_setup_periodic_tasks"
_PG_MIGRATION = "dashboard_metrics.migrations.0004_pg_periodic_tasks"

# Cron equivalent of each Beat schedule, asserted against what the Beat migration builds.
# Written out rather than derived: deriving it from the same code under test would make
# the comparison vacuous.
_EXPECTED_CRON = {
"dashboard_metrics_aggregate_from_sources": "*/15 * * * *",
"dashboard_metrics_cleanup_hourly": "0 2 * * *",
"dashboard_metrics_cleanup_daily": "0 3 * * 0",
}


@pytest.fixture(scope="module")
def pg_specs() -> dict[str, dict]:
mod = importlib.import_module(_PG_MIGRATION)
return {spec["name"]: spec for spec in mod.PG_PERIODIC_TASKS}


class _FakeQuerySet:
"""Captures update_or_create calls from the Beat migration without a database."""

def __init__(self, sink: dict):
self._sink = sink

def get_or_create(self, **kwargs):
# Schedule rows (Interval/Crontab) — return the kwargs so the PeriodicTask
# call can be inspected for which schedule it was given.
return kwargs, True

def update_or_create(self, name=None, defaults=None, **_kw):
self._sink[name] = defaults or {}
return defaults, True

def filter(self, *_a, **_k):
return self

def delete(self):
return (0, {})


@pytest.fixture(scope="module")
def beat_specs() -> dict[str, dict]:
"""Run the Beat migration's forward function against fakes and capture what it declares."""
mod = importlib.import_module(_BEAT_MIGRATION)
captured: dict[str, dict] = {}

class _Apps:
def get_model(self, _app, model):
if model == "PeriodicTask":
return type("PT", (), {"objects": _FakeQuerySet(captured)})
return type("S", (), {"objects": _FakeQuerySet({})})

mod.create_periodic_tasks(_Apps(), None)
return captured


class TestDeclarationsAgree:
def test_same_set_of_schedules(self, beat_specs, pg_specs):
# A schedule added to Beat but not PG stops firing the moment the flag flips;
# the reverse fires something Beat never knew about.
assert set(beat_specs) == set(pg_specs)

@pytest.mark.parametrize("name", sorted(_EXPECTED_CRON))
def test_task_path_and_queue_match(self, beat_specs, pg_specs, name):
assert pg_specs[name]["task_name"] == beat_specs[name]["task"]
assert pg_specs[name]["queue"] == beat_specs[name]["queue"]

@pytest.mark.parametrize("name", sorted(_EXPECTED_CRON))
def test_kwargs_match_once_decoded(self, beat_specs, pg_specs, name):
# Beat stores kwargs as a JSON *string*; PgPeriodicTask.task_kwargs is a
# JSONField. A mismatch here means the cleanup runs with the wrong retention.
beat_kwargs = json.loads(beat_specs[name].get("kwargs") or "{}")
assert pg_specs[name]["task_kwargs"] == beat_kwargs

@pytest.mark.parametrize("name,cron", sorted(_EXPECTED_CRON.items()))
def test_cron_matches_the_beat_cadence(self, pg_specs, name, cron):
assert pg_specs[name]["cron_string"] == cron


class TestSeededInert:
"""Applying the migration must not cause anything to fire."""

def test_no_spec_declares_itself_pg_owned(self, pg_specs):
# pg_owned is set to False in the migration's defaults, never from the spec —
# this pins that no spec can smuggle ownership in.
assert not any("pg_owned" in spec for spec in pg_specs.values())

def test_no_spec_presets_a_run_time(self, pg_specs):
# A non-NULL next_run_at in the past would read as "overdue" and fire a burst
# of catch-up runs the moment the flag is enabled.
for spec in pg_specs.values():
assert "next_run_at" not in spec
assert "last_run_at" not in spec
Loading
Loading