feat(background-execution): DB-derived worker_online/busy/idle gauges

Replace langflow_bg_alive_workers with three registry-derived gauges
(online/busy/idle) the collector reads from worker_registry via
WorkerRegistryService.count_by_state each tick, and prune stale rows
past retention once per tick (best-effort). online_window is 3x the
worker registry interval; both interval and retention are threaded from
settings at the collector construction site.
This commit is contained in:
ogabrielluiz
2026-06-05 14:25:16 -03:00
parent 39a73becb3
commit 77e355ae92
6 changed files with 354 additions and 150 deletions

View File

@ -9,9 +9,10 @@ writes — Task 7 wires these into the OTel gauges.
timezone-aware column, but SQLite hands it back naive; we normalize to aware
UTC before subtracting so the math is valid on both SQLite and Postgres.
The "alive" worker definition reuses ``JobService.is_lease_stale`` semantics so
it matches the watchdog exactly: a heartbeat is FRESH while its age is within
the lease window (``age <= lease_window``), stale once older.
The worker online/busy/idle gauges are derived from the durable
``worker_registry`` table (``WorkerRegistryService.count_by_state``): a row is
online while ``last_heartbeat >= now - online_window`` (online_window = 3x the
worker's registry interval). The collector also prunes rows past retention.
"""
from __future__ import annotations
@ -26,7 +27,8 @@ from lfx.log.logger import logger
from sqlmodel import col, func, select
from langflow.services.background_execution.metrics import current_backend
from langflow.services.database.models.jobs.model import Job, JobStatus
from langflow.services.background_execution.worker_registry import WorkerRegistryService
from langflow.services.database.models.jobs.model import Job, JobEvent, JobStatus
from langflow.services.deps import get_telemetry_service, session_scope
if TYPE_CHECKING:
@ -38,20 +40,41 @@ if TYPE_CHECKING:
NONTERMINAL_STATUSES = (JobStatus.QUEUED, JobStatus.IN_PROGRESS)
async def count_nonterminal_jobs(session) -> dict[str, int]:
"""Return counts of non-terminal jobs keyed by status string.
def _has_job_events():
"""EXISTS subquery: the job has at least one ``job_events`` row.
``{"queued": 3, "in_progress": 1}`` via a ``GROUP BY status`` aggregate
filtered to the non-terminal set. Statuses with zero rows are omitted (the
collector loop fills in the canonical set with 0 when it sets gauges).
Distinguishes a TRUE background job from a Memory-Base "run job". Every flow
build writes a second ``job`` row keyed by the graph run_id (``build.py``,
predates the bg work); it is ``type=workflow`` too, so a naive status query
double-counts it. The background runner uniquely appends ``job_events`` rows,
while run jobs never have any (they go straight IN_PROGRESS->terminal via
``execute_with_status`` and never QUEUED). So a background job is
``status == QUEUED`` OR EXISTS(job_events). ``job_id`` is indexed
(UNIQUE(job_id, seq)), so the EXISTS is cheap and dialect-agnostic.
"""
stmt = select(Job.status, func.count()).where(col(Job.status).in_(NONTERMINAL_STATUSES)).group_by(Job.status)
result = await session.exec(stmt)
return select(JobEvent.id).where(col(JobEvent.job_id) == Job.job_id).exists()
async def count_nonterminal_jobs(session) -> dict[str, int]:
"""Return counts of non-terminal background jobs keyed by status string.
``{"queued": 3, "in_progress": 1}``. QUEUED is bg-only (run jobs are never
queued), so it counts as-is. IN_PROGRESS adds the EXISTS(job_events) filter
because a run job is briefly IN_PROGRESS with no events — without the filter
it would inflate the in_progress count. A status with zero rows is omitted
(the collector loop fills in the canonical set with 0 when it sets gauges).
"""
queued = (await session.exec(select(func.count()).select_from(Job).where(Job.status == JobStatus.QUEUED))).one()
in_progress = (
await session.exec(
select(func.count()).select_from(Job).where(Job.status == JobStatus.IN_PROGRESS).where(_has_job_events())
)
).one()
counts: dict[str, int] = {}
for status, count in result.all():
# ``status`` is a JobStatus enum; ``.value`` is the wire string.
key = status.value if isinstance(status, JobStatus) else str(status)
counts[key] = int(count)
if int(queued):
counts[JobStatus.QUEUED.value] = int(queued)
if int(in_progress):
counts[JobStatus.IN_PROGRESS.value] = int(in_progress)
return counts
@ -75,38 +98,15 @@ async def oldest_queued_seconds(session, now: datetime) -> float:
return max(age, 0.0)
async def alive_worker_count(session, now: datetime, lease_window: float) -> int:
"""Count distinct heartbeat owners whose lease is still fresh.
async def worker_state_counts(session, now: datetime, online_window: timedelta) -> dict[str, int]:
"""Aggregate online/busy/idle worker counts from the durable ``worker_registry``.
Fresh means ``heartbeat_at >= now - lease_window`` the exact inverse of
``JobService.is_lease_stale`` (stale once ``age > lease_window``), so a
worker counted "alive" here is one the watchdog would NOT reconcile. The
heartbeat lease lives in ``job_metadata`` (``owner`` + ``heartbeat_at`` ISO
string), stamped only on IN_PROGRESS rows by ``JobService.heartbeat``.
Parsing the ISO ``heartbeat_at`` in Python (rather than a cross-dialect
JSON-string SQL comparison) keeps the boundary identical to is_lease_stale
on both SQLite and Postgres.
Thin wrapper over ``WorkerRegistryService.count_by_state`` so the collector
derives its fleet gauges from the same source of truth the worker loop writes.
A row is online when ``last_heartbeat >= now - online_window``; busy/idle split
online rows by state. ``now`` is injected for deterministic freshness math.
"""
stmt = select(Job.job_metadata).where(Job.status == JobStatus.IN_PROGRESS)
result = await session.exec(stmt)
alive_owners: set[str] = set()
for row in result.all():
meta = row or {}
owner = meta.get("owner")
raw = meta.get("heartbeat_at")
if not owner or not raw:
continue
try:
hb = datetime.fromisoformat(raw)
except (TypeError, ValueError):
continue
if hb.tzinfo is None:
hb = hb.replace(tzinfo=timezone.utc)
age = (now - hb).total_seconds()
if age <= lease_window:
alive_owners.add(owner)
return len(alive_owners)
return await WorkerRegistryService().count_by_state(session, now=now, online_window=online_window)
def _error_type_expr(session):
@ -136,25 +136,32 @@ async def terminal_counts(session) -> dict[str, int]:
Returns ``{"started", "completed", "failed_error", "failed_worker_lost",
"timed_out", "cancelled"}``:
* ``started`` — every job that has begun: ``status != QUEUED`` (IN_PROGRESS
plus every terminal state).
* ``started`` — every background job that has begun: ``status != QUEUED``
AND EXISTS(job_events) (IN_PROGRESS plus every terminal state).
* ``completed`` — ``status == COMPLETED``.
* FAILED jobs split by ``error->>'type'``: ``failed_worker_lost`` is FAILED
AND ``type == 'worker_lost'``; ``failed_error`` is every other FAILED row.
* ``timed_out`` / ``cancelled`` — the matching terminal statuses.
The worker_lost split uses a dialect-aware JSON extract (see
``_error_type_expr``) so it stays a single bounded SQL aggregate on both
SQLite and Postgres.
Every non-queued count adds the EXISTS(job_events) filter so a Memory-Base
"run job" (build.py's second workflow row, which never appends job_events)
is excluded — without it the counts double (see ``_has_job_events``). The
worker_lost split uses a dialect-aware JSON extract (see ``_error_type_expr``)
so it stays a single bounded SQL aggregate on both SQLite and Postgres.
"""
started_stmt = select(func.count()).select_from(Job).where(Job.status != JobStatus.QUEUED)
completed_stmt = select(func.count()).select_from(Job).where(Job.status == JobStatus.COMPLETED)
timed_out_stmt = select(func.count()).select_from(Job).where(Job.status == JobStatus.TIMED_OUT)
cancelled_stmt = select(func.count()).select_from(Job).where(Job.status == JobStatus.CANCELLED)
has_events = _has_job_events()
started_stmt = select(func.count()).select_from(Job).where(Job.status != JobStatus.QUEUED).where(has_events)
completed_stmt = select(func.count()).select_from(Job).where(Job.status == JobStatus.COMPLETED).where(has_events)
timed_out_stmt = select(func.count()).select_from(Job).where(Job.status == JobStatus.TIMED_OUT).where(has_events)
cancelled_stmt = select(func.count()).select_from(Job).where(Job.status == JobStatus.CANCELLED).where(has_events)
error_type = _error_type_expr(session)
worker_lost_stmt = (
select(func.count()).select_from(Job).where(Job.status == JobStatus.FAILED).where(error_type == "worker_lost")
select(func.count())
.select_from(Job)
.where(Job.status == JobStatus.FAILED)
.where(has_events)
.where(error_type == "worker_lost")
)
started = (await session.exec(started_stmt)).one()
@ -166,7 +173,9 @@ async def terminal_counts(session) -> dict[str, int]:
# with a NULL error or no ``type`` key, so count FAILED-not-worker_lost as the
# complement of worker_lost to capture those rows too.
failed_total = (
await session.exec(select(func.count()).select_from(Job).where(Job.status == JobStatus.FAILED))
await session.exec(
select(func.count()).select_from(Job).where(Job.status == JobStatus.FAILED).where(has_events)
)
).one()
return {
@ -196,6 +205,9 @@ async def duration_percentiles(session, now: datetime, window_seconds: float) ->
boundary rows), so SQLite gets a naive-UTC cutoff that matches the stored
format. Naive SQLite datetimes are still normalized to aware UTC before
subtracting, the same way ``oldest_queued_seconds`` does.
Only TRUE background jobs are considered (EXISTS(job_events)) so a run job's
near-instant duration does not skew p50/p95 — see ``_has_job_events``.
"""
cutoff = now - timedelta(seconds=window_seconds)
# Bind the cutoff in the form the stored column uses: aware for postgres,
@ -206,6 +218,7 @@ async def duration_percentiles(session, now: datetime, window_seconds: float) ->
select(Job.created_timestamp, Job.finished_timestamp)
.where(col(Job.finished_timestamp).is_not(None))
.where(col(Job.finished_timestamp) >= sql_cutoff)
.where(_has_job_events())
)
result = await session.exec(stmt)
durations: list[float] = []
@ -242,26 +255,41 @@ class BackgroundMetricsCollector:
Best-effort: a failing tick logs a warning and returns without raising so the
loop keeps running — observability must never crash the service.
``lease_window`` defaults to ``45.0`` to match the runtime
``background_lease_ttl_s`` setting that ``sweep_orphans`` / ``requeue_lost``
are actually called with, so "alive workers" agrees with what the watchdog
reconciles. Task 8 passes ``settings.background_lease_ttl_s`` explicitly.
``registry_interval`` is the worker's ``background_worker_registry_interval_s``;
the online window is ``3 * registry_interval`` (a worker is online while it has
beat within three of its own intervals). ``registry_retention_s`` is how long a
stale row is kept before the per-tick prune removes it. Both default from the
settings at the construction site (``maybe_start_metrics_collector``).
"""
def __init__(self, *, interval: float, lease_window: float = 45.0, duration_window_seconds: float = 300.0):
def __init__(
self,
*,
interval: float,
registry_interval: float = 10.0,
registry_retention_s: float = 3600.0,
duration_window_seconds: float = 300.0,
):
self.interval = interval
self.lease_window = lease_window
self.registry_interval = registry_interval
self.registry_retention_s = registry_retention_s
self.duration_window_seconds = duration_window_seconds
self._stopped = False
self._task: asyncio.Task | None = None
async def collect_once(self, session) -> None:
"""Run the three queries and push the gauges. Never raises."""
async def collect_once(self, session, *, now: datetime | None = None) -> None:
"""Run the queries and push the gauges. Never raises.
``now`` defaults to the wall clock so the loop owns the tick's clock; tests
inject an explicit ``now`` for deterministic freshness/retention math.
"""
try:
now = datetime.now(timezone.utc)
if now is None:
now = datetime.now(timezone.utc)
counts = await count_nonterminal_jobs(session)
oldest = await oldest_queued_seconds(session, now)
alive = await alive_worker_count(session, now, self.lease_window)
online_window = timedelta(seconds=3 * self.registry_interval)
worker_counts = await worker_state_counts(session, now, online_window)
backend = current_backend()
ot = get_telemetry_service().ot
@ -275,7 +303,9 @@ class BackgroundMetricsCollector:
{"status": status.value, "backend": backend},
)
ot.update_gauge("langflow_bg_oldest_queued_seconds", oldest, {"backend": backend})
ot.update_gauge("langflow_bg_alive_workers", alive, {"backend": backend})
ot.update_gauge("langflow_bg_workers_online", worker_counts["online"], {"backend": backend})
ot.update_gauge("langflow_bg_workers_busy", worker_counts["busy"], {"backend": backend})
ot.update_gauge("langflow_bg_workers_idle", worker_counts["idle"], {"backend": backend})
# Cumulative all-time throughput/outcome counts, set as observable
# counters (last-absolute-value-wins per label-set). The worker runs
@ -303,6 +333,14 @@ class BackgroundMetricsCollector:
p50, p95 = await duration_percentiles(session, now, self.duration_window_seconds)
ot.update_gauge("langflow_bg_job_duration_p50_seconds", p50, {"backend": backend})
ot.update_gauge("langflow_bg_job_duration_p95_seconds", p95, {"backend": backend})
# Best-effort prune of crashed-worker rows past retention. Guarded on its
# own so a prune failure (e.g. a write contention) does not drop the gauges
# already set this tick — the next tick retries.
try:
await WorkerRegistryService().prune_stale(session, now=now, retention_s=self.registry_retention_s)
except Exception as exc: # noqa: BLE001 - prune is best-effort, never breaks the tick
logger.warning(f"bg worker_registry prune skipped: {exc}")
except Exception as exc: # noqa: BLE001 - observability must never crash the loop
logger.warning(f"bg metrics collection tick skipped: {exc}")
@ -346,7 +384,8 @@ async def maybe_start_metrics_collector(app: FastAPI, settings: Any, *, promethe
try:
collector = BackgroundMetricsCollector(
interval=settings.background_metrics_interval,
lease_window=settings.background_lease_ttl_s,
registry_interval=settings.background_worker_registry_interval_s,
registry_retention_s=settings.background_worker_registry_retention_s,
)
collector.start()
app.state.background_metrics_collector = collector

View File

@ -180,8 +180,22 @@ class OpenTelemetry(metaclass=ThreadSafeSingletonMetaUsingWeakref):
labels={"backend": mandatory_label},
)
self._add_metric(
name="langflow_bg_alive_workers",
description="Distinct background workers heartbeating within the lease window",
name="langflow_bg_workers_online",
description="Background workers heartbeating within the online window",
unit="",
metric_type=MetricType.OBSERVABLE_GAUGE,
labels={"backend": mandatory_label},
)
self._add_metric(
name="langflow_bg_workers_busy",
description="Online background workers currently running a job",
unit="",
metric_type=MetricType.OBSERVABLE_GAUGE,
labels={"backend": mandatory_label},
)
self._add_metric(
name="langflow_bg_workers_idle",
description="Online background workers currently idle",
unit="",
metric_type=MetricType.OBSERVABLE_GAUGE,
labels={"backend": mandatory_label},

View File

@ -5,10 +5,10 @@ They run against the REAL test DB (the ``client`` fixture; SQLite locally,
Postgres in CI) with NO mocking. ``now`` is injected so the time math is
deterministic and never races the wall clock.
The "alive" definition MUST match the watchdog's lease-staleness boundary
(``JobService.is_lease_stale``): a heartbeat is FRESH while its age is within
the lease window, so ``alive_worker_count`` counts owners with
``heartbeat_at >= now - lease_window``.
The worker online/busy/idle gauges are derived from the durable
``worker_registry`` table via ``WorkerRegistryService.count_by_state``: a row is
online while ``last_heartbeat >= now - online_window`` (online_window = 3x the
worker's registry interval), and busy/idle split the online rows by state.
"""
from __future__ import annotations
@ -20,13 +20,13 @@ import pytest
from langflow.services.background_execution.metrics import current_backend
from langflow.services.background_execution.metrics_collector import (
BackgroundMetricsCollector,
alive_worker_count,
count_nonterminal_jobs,
duration_percentiles,
oldest_queued_seconds,
terminal_counts,
)
from langflow.services.database.models.jobs.model import JobStatus
from langflow.services.database.models.worker_registry.model import WorkerRegistry, WorkerState
from langflow.services.deps import get_telemetry_service, session_scope
from langflow.services.jobs.service import JobService
@ -34,20 +34,26 @@ pytestmark = pytest.mark.usefixtures("client")
async def test_count_nonterminal_jobs_excludes_terminal():
"""Only non-terminal statuses are counted, keyed by status string."""
"""Only non-terminal background statuses are counted, keyed by status string."""
service = JobService()
queued_a = uuid4()
queued_b = uuid4()
in_progress = uuid4()
completed = uuid4()
run_in_progress = uuid4()
await service.create_job(job_id=queued_a, flow_id=uuid4(), user_id=uuid4())
await service.create_job(job_id=queued_b, flow_id=uuid4(), user_id=uuid4())
await service.create_job(job_id=in_progress, flow_id=uuid4(), user_id=uuid4())
await service.append_event(in_progress, "run_started", {})
await service.update_job_status(in_progress, JobStatus.IN_PROGRESS)
# A RUN job briefly IN_PROGRESS with NO job_events — must be excluded.
await service.create_job(job_id=run_in_progress, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(run_in_progress, JobStatus.IN_PROGRESS)
# Terminal: must be excluded from the non-terminal aggregate.
await service.create_job(job_id=completed, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(completed, JobStatus.COMPLETED, finished_timestamp=True)
@ -97,61 +103,6 @@ async def test_oldest_queued_seconds_zero_when_none_queued():
assert age == 0.0
async def test_alive_worker_count_excludes_stale_heartbeats():
"""Distinct owners with a FRESH heartbeat (within the lease window) are alive.
Two owners heartbeat fresh; one owner's heartbeat is older than the window.
The stale owner is excluded, matching ``is_lease_stale``.
"""
service = JobService()
lease_window = 30.0
fresh_a = uuid4()
fresh_b = uuid4()
stale = uuid4()
await service.create_job(job_id=fresh_a, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(fresh_a, JobStatus.IN_PROGRESS)
await service.heartbeat(fresh_a, owner="worker-A")
await service.create_job(job_id=fresh_b, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(fresh_b, JobStatus.IN_PROGRESS)
await service.heartbeat(fresh_b, owner="worker-B")
await service.create_job(job_id=stale, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(stale, JobStatus.IN_PROGRESS)
old = (datetime.now(timezone.utc) - timedelta(seconds=300)).isoformat()
await service.update_job_metadata(stale, {"owner": "worker-dead", "heartbeat_at": old})
now = datetime.now(timezone.utc)
async with session_scope() as session:
alive = await alive_worker_count(session, now, lease_window)
assert alive == 2
async def test_alive_worker_count_distinct_owner():
"""Multiple fresh heartbeats from the SAME owner count once."""
service = JobService()
lease_window = 30.0
job_one = uuid4()
job_two = uuid4()
await service.create_job(job_id=job_one, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(job_one, JobStatus.IN_PROGRESS)
await service.heartbeat(job_one, owner="worker-A")
await service.create_job(job_id=job_two, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(job_two, JobStatus.IN_PROGRESS)
await service.heartbeat(job_two, owner="worker-A")
now = datetime.now(timezone.utc)
async with session_scope() as session:
alive = await alive_worker_count(session, now, lease_window)
assert alive == 1
def _gauge_value(metric_name: str, labels: dict[str, str]) -> float:
"""Read a gauge value straight off the real OTel ObservableGaugeWrapper.
@ -177,8 +128,8 @@ async def test_collect_once_sets_gauges():
await service.create_job(job_id=queued_b, flow_id=uuid4(), user_id=uuid4())
await service.create_job(job_id=in_progress, flow_id=uuid4(), user_id=uuid4())
await service.append_event(in_progress, "run_started", {})
await service.update_job_status(in_progress, JobStatus.IN_PROGRESS)
await service.heartbeat(in_progress, owner="worker-A")
await service.create_job(job_id=completed, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(completed, JobStatus.COMPLETED, finished_timestamp=True)
@ -190,7 +141,6 @@ async def test_collect_once_sets_gauges():
assert _gauge_value("langflow_bg_jobs", {"status": "queued", "backend": backend}) == 2
assert _gauge_value("langflow_bg_jobs", {"status": "in_progress", "backend": backend}) == 1
assert _gauge_value("langflow_bg_oldest_queued_seconds", {"backend": backend}) > 0
assert _gauge_value("langflow_bg_alive_workers", {"backend": backend}) == 1
async def test_collect_once_zero_fills_dropped_status():
@ -253,16 +203,31 @@ async def test_run_stop_lifecycle():
async def _seed_failed(service: JobService, *, error: dict):
"""Create a job, flip it FAILED, and stamp the given error blob."""
"""Create a BACKGROUND job (with a job_events row), flip FAILED, stamp error."""
job_id = uuid4()
await service.create_job(job_id=job_id, flow_id=uuid4(), user_id=uuid4())
# A job_events row is what marks this as a TRUE background job (vs a run job).
await service.append_event(job_id, "run_started", {})
await service.update_job_status(job_id, JobStatus.FAILED, finished_timestamp=True)
await service.set_error(job_id, error)
return job_id
async def _seed_terminal(service: JobService, status: JobStatus):
"""Create a job and flip it to the given terminal status."""
"""Create a BACKGROUND job (with a job_events row) and flip it terminal."""
job_id = uuid4()
await service.create_job(job_id=job_id, flow_id=uuid4(), user_id=uuid4())
await service.append_event(job_id, "run_started", {})
await service.update_job_status(job_id, status, finished_timestamp=True)
return job_id
async def _seed_run_job(service: JobService, status: JobStatus = JobStatus.COMPLETED):
"""Create a Memory-Base RUN job: created directly, terminal, NO job_events.
Mimics build.py's second workflow row — it must be excluded by the collector's
EXISTS(job_events) filter.
"""
job_id = uuid4()
await service.create_job(job_id=job_id, flow_id=uuid4(), user_id=uuid4())
await service.update_job_status(job_id, status, finished_timestamp=True)
@ -271,7 +236,7 @@ async def _seed_terminal(service: JobService, status: JobStatus):
async def test_terminal_counts_splits_outcomes():
"""terminal_counts returns the per-outcome split; started excludes only QUEUED."""
from langflow.services.database.models.jobs.model import Job
from langflow.services.database.models.jobs.model import Job, JobEvent
service = JobService()
@ -279,6 +244,7 @@ async def test_terminal_counts_splits_outcomes():
await service.create_job(job_id=uuid4(), flow_id=uuid4(), user_id=uuid4())
in_progress = uuid4()
await service.create_job(job_id=in_progress, flow_id=uuid4(), user_id=uuid4())
await service.append_event(in_progress, "run_started", {})
await service.update_job_status(in_progress, JobStatus.IN_PROGRESS)
# 2 COMPLETED.
@ -294,14 +260,21 @@ async def test_terminal_counts_splits_outcomes():
await _seed_terminal(service, JobStatus.TIMED_OUT)
await _seed_terminal(service, JobStatus.CANCELLED)
# RUN jobs (no job_events) in several terminal states — must be excluded.
await _seed_run_job(service, JobStatus.COMPLETED)
await _seed_run_job(service, JobStatus.FAILED)
async with session_scope() as session:
tc = await terminal_counts(session)
# Absolute totals in a shared test DB are not isolated across tests, so
# assert against the actual current rows by status to keep this robust.
# assert against the actual current BACKGROUND rows (EXISTS(job_events))
# by status to keep this robust and to exclude run jobs.
from sqlmodel import col, func, select
has_events = select(JobEvent.id).where(col(JobEvent.job_id) == Job.job_id).exists()
async def _count(*statuses):
stmt = select(func.count()).select_from(Job).where(col(Job.status).in_(statuses))
stmt = select(func.count()).select_from(Job).where(col(Job.status).in_(statuses)).where(has_events)
return int((await session.exec(stmt)).one())
total = await _count(*list(JobStatus))
@ -315,13 +288,38 @@ async def test_terminal_counts_splits_outcomes():
assert tc["completed"] == completed
assert tc["timed_out"] == timed_out
assert tc["cancelled"] == cancelled
# The split must partition all FAILED rows.
# The split must partition all FAILED background rows.
assert tc["failed_worker_lost"] + tc["failed_error"] == failed
# We seeded exactly two worker_lost rows; any pre-existing FAILED rows from
# other tests carry a different/absent type and land in failed_error.
assert tc["failed_worker_lost"] >= 2
async def test_terminal_counts_excludes_run_jobs():
"""A COMPLETED run job (no job_events) must NOT count toward completed.
This is the 2x-inflation guard: build.py writes a second workflow row per
flow build that goes straight to COMPLETED with no job_events. The collector
must count only the TRUE background job (EXISTS(job_events)).
"""
service = JobService()
async with session_scope() as session:
before = (await terminal_counts(session))["completed"]
# One real background completion (+1) and three run-job completions (+0).
await _seed_terminal(service, JobStatus.COMPLETED)
await _seed_run_job(service, JobStatus.COMPLETED)
await _seed_run_job(service, JobStatus.COMPLETED)
await _seed_run_job(service, JobStatus.COMPLETED)
async with session_scope() as session:
after = (await terminal_counts(session))["completed"]
# Only the single background job moved the needle; the three run jobs did not.
assert after - before == 1
async def test_duration_percentiles_deterministic():
"""p50/p95 over jobs finished within the window match known durations."""
from langflow.services.database.models.jobs.model import Job
@ -329,12 +327,14 @@ async def test_duration_percentiles_deterministic():
service = JobService()
now = datetime.now(timezone.utc)
# Seed five COMPLETED jobs with known durations 10..50s, all finished now.
# Seed five COMPLETED BACKGROUND jobs (with job_events) with known durations
# 10..50s, all finished now.
durations = [10, 20, 30, 40, 50]
job_ids = []
for _ in durations:
jid = uuid4()
await service.create_job(job_id=jid, flow_id=uuid4(), user_id=uuid4())
await service.append_event(jid, "run_started", {})
await service.update_job_status(jid, JobStatus.COMPLETED, finished_timestamp=True)
job_ids.append(jid)
@ -376,8 +376,13 @@ async def test_duration_percentiles_excludes_jobs_outside_window():
recent = uuid4()
for jid in (old, recent):
await service.create_job(job_id=jid, flow_id=uuid4(), user_id=uuid4())
await service.append_event(jid, "run_started", {})
await service.update_job_status(jid, JobStatus.COMPLETED, finished_timestamp=True)
# A RUN job (no job_events) finished AT base with a 5000s duration — inside
# the window but must be excluded by EXISTS(job_events), so it never skews p95.
run_job = await _seed_run_job(service, JobStatus.COMPLETED)
async with session_scope() as session:
old_job = await session.get(Job, old)
old_job.finished_timestamp = base - timedelta(hours=1)
@ -387,11 +392,16 @@ async def test_duration_percentiles_excludes_jobs_outside_window():
recent_job.finished_timestamp = base
recent_job.created_timestamp = base - timedelta(seconds=7)
session.add(recent_job)
run_row = await session.get(Job, run_job)
run_row.finished_timestamp = base
run_row.created_timestamp = base - timedelta(seconds=5000)
session.add(run_row)
await session.flush()
# 60s window back from ``base``: the recent job (finished AT base) is in; the
# old job (finished an hour earlier) is out. Only the 7s sample remains, so
# p50 == p95 == 7 and the 999s duration never leaks in.
# old job (finished an hour earlier) is out by the SQL cutoff; the run job is
# out by EXISTS(job_events). Only the 7s sample remains, so p50 == p95 == 7
# and neither the 999s nor the 5000s duration leaks in.
async with session_scope() as session:
p50, p95 = await duration_percentiles(session, base, window_seconds=60.0)
@ -439,10 +449,11 @@ async def test_collect_once_sets_counters_and_duration_gauges():
await _seed_terminal(service, JobStatus.CANCELLED),
]
# A COMPLETED job with a known 12s duration finished now so the duration
# gauges are non-zero with a tight window.
# A COMPLETED BACKGROUND job (with a job_events row) with a known 12s
# duration finished now so the duration gauges are non-zero with a tight window.
dur_job = uuid4()
await service.create_job(job_id=dur_job, flow_id=uuid4(), user_id=uuid4())
await service.append_event(dur_job, "run_started", {})
await service.update_job_status(dur_job, JobStatus.COMPLETED, finished_timestamp=True)
async with session_scope() as session:
# Push every other seeded job's finish well outside the duration window
@ -490,3 +501,138 @@ async def test_collect_once_sets_counters_and_duration_gauges():
# wires p50/p95 from a real finished-job sample).
assert _gauge_value("langflow_bg_job_duration_p50_seconds", {"backend": backend}) > 0.0
assert _gauge_value("langflow_bg_job_duration_p95_seconds", {"backend": backend}) > 0.0
async def test_collect_once_sets_worker_gauges_from_registry():
"""A tick derives online/busy/idle from worker_registry; stale rows count toward none.
online_window = 3 * registry_interval = 30s. Seed a fresh IDLE, a fresh BUSY,
and a STALE row (heartbeat older than 30s). ``now`` is injected so the freshness
boundary is deterministic and never races the wall clock.
"""
backend = current_backend()
now = datetime(2026, 6, 5, 12, 0, 0, tzinfo=timezone.utc)
suffix = uuid4().hex
async with session_scope() as session:
session.add(
WorkerRegistry(
owner=f"fresh-idle-{suffix}",
pid=1,
host="h",
started_at=now - timedelta(minutes=5),
last_heartbeat=now,
state=WorkerState.IDLE,
current_job_id=None,
)
)
session.add(
WorkerRegistry(
owner=f"fresh-busy-{suffix}",
pid=2,
host="h",
started_at=now - timedelta(minutes=5),
last_heartbeat=now - timedelta(seconds=10),
state=WorkerState.BUSY,
current_job_id=uuid4(),
)
)
# Stale: heartbeat 60s ago, past the 30s online window — excluded everywhere,
# but within the 1h retention so prune does not delete it this tick.
session.add(
WorkerRegistry(
owner=f"stale-{suffix}",
pid=3,
host="h",
started_at=now - timedelta(minutes=5),
last_heartbeat=now - timedelta(seconds=60),
state=WorkerState.IDLE,
current_job_id=None,
)
)
await session.flush()
# registry_interval=10 -> online_window 30s; retention 1h leaves the stale row.
collector = BackgroundMetricsCollector(
interval=15.0,
registry_interval=10.0,
registry_retention_s=3600.0,
)
async with session_scope() as session:
await collector.collect_once(session, now=now)
assert _gauge_value("langflow_bg_workers_online", {"backend": backend}) == 2
assert _gauge_value("langflow_bg_workers_busy", {"backend": backend}) == 1
assert _gauge_value("langflow_bg_workers_idle", {"backend": backend}) == 1
# The stale-but-within-retention row is still present (prune did not remove it).
async with session_scope() as session:
assert await session.get(WorkerRegistry, f"stale-{suffix}") is not None
async def test_collect_once_prunes_rows_past_retention():
"""A tick prunes registry rows older than retention while keeping recent ones.
With retention=3600s, a row whose heartbeat is 5h old is deleted; one whose
heartbeat is 10s old survives. ``now`` is injected so the cutoff is exact.
"""
now = datetime(2026, 6, 5, 12, 0, 0, tzinfo=timezone.utc)
suffix = uuid4().hex
keep = f"keep-{suffix}"
drop = f"drop-{suffix}"
async with session_scope() as session:
session.add(
WorkerRegistry(
owner=keep,
pid=1,
host="h",
started_at=now - timedelta(hours=6),
last_heartbeat=now - timedelta(seconds=10),
state=WorkerState.IDLE,
current_job_id=None,
)
)
session.add(
WorkerRegistry(
owner=drop,
pid=2,
host="h",
started_at=now - timedelta(hours=6),
last_heartbeat=now - timedelta(hours=5),
state=WorkerState.IDLE,
current_job_id=None,
)
)
await session.flush()
collector = BackgroundMetricsCollector(
interval=15.0,
registry_interval=10.0,
registry_retention_s=3600.0,
)
async with session_scope() as session:
await collector.collect_once(session, now=now)
async with session_scope() as session:
assert await session.get(WorkerRegistry, keep) is not None
assert await session.get(WorkerRegistry, drop) is None
async def test_collect_once_no_longer_emits_alive_workers():
"""The replaced langflow_bg_alive_workers gauge is gone; the rest still emit."""
backend = current_backend()
now = datetime(2026, 6, 5, 12, 0, 0, tzinfo=timezone.utc)
collector = BackgroundMetricsCollector(interval=15.0, registry_interval=10.0)
async with session_scope() as session:
await collector.collect_once(session, now=now)
ot = get_telemetry_service().ot
# The metric is no longer registered, so update_gauge would raise on it.
assert "langflow_bg_alive_workers" not in ot._metrics
assert "langflow_bg_alive_workers" not in ot._metrics_registry
# The rest of the collector's gauges still emit on this tick.
assert "langflow_bg_workers_online" in ot._metrics
assert _gauge_value("langflow_bg_oldest_queued_seconds", {"backend": backend}) >= 0.0

View File

@ -62,8 +62,11 @@ async def test_started_when_port_bound_and_enabled():
# The loop task is live: created and not yet finished.
assert collector._task is not None
assert not collector._task.done()
# The lease window is wired from settings so "alive" agrees with the watchdog.
assert collector.lease_window == _settings(prometheus_enabled=True).background_lease_ttl_s
# The registry interval/retention are wired from settings so the online window
# (3x interval) and the per-tick prune match the worker's own registry config.
settings = _settings(prometheus_enabled=True)
assert collector.registry_interval == settings.background_worker_registry_interval_s
assert collector.registry_retention_s == settings.background_worker_registry_retention_s
await stop_metrics_collector(app)
# Stop actually ended the loop: task cleared and the underlying task finished.

View File

@ -10,7 +10,9 @@ def ot():
BG_METRICS = {
"langflow_bg_jobs": MetricType.OBSERVABLE_GAUGE,
"langflow_bg_oldest_queued_seconds": MetricType.OBSERVABLE_GAUGE,
"langflow_bg_alive_workers": MetricType.OBSERVABLE_GAUGE,
"langflow_bg_workers_online": MetricType.OBSERVABLE_GAUGE,
"langflow_bg_workers_busy": MetricType.OBSERVABLE_GAUGE,
"langflow_bg_workers_idle": MetricType.OBSERVABLE_GAUGE,
"langflow_bg_jobs_started_total": MetricType.OBSERVABLE_COUNTER,
"langflow_bg_jobs_completed_total": MetricType.OBSERVABLE_COUNTER,
"langflow_bg_jobs_failed_total": MetricType.OBSERVABLE_COUNTER,
@ -36,4 +38,4 @@ def test_set_observable_counter_sets_readable_value(ot):
def test_set_observable_counter_on_non_observable_counter_raises(ot):
with pytest.raises(TypeError):
ot.set_observable_counter("langflow_bg_alive_workers", 1, {"backend": "scaled"})
ot.set_observable_counter("langflow_bg_workers_online", 1, {"backend": "scaled"})

View File

@ -22,11 +22,11 @@ def test_otel_metrics_reach_prometheus_exposition():
async def main():
ts = get_telemetry_service()
ts.ot.set_observable_counter("langflow_bg_jobs_started_total", 1, {"backend": "scaled"})
ts.ot.update_gauge("langflow_bg_alive_workers", 2, {"backend": "scaled"})
ts.ot.update_gauge("langflow_bg_workers_online", 2, {"backend": "scaled"})
from prometheus_client import generate_latest
out = generate_latest().decode()
assert "langflow_bg_jobs_started_total" in out, "counter missing from exposition"
assert "langflow_bg_alive_workers" in out, "gauge missing from exposition"
assert "langflow_bg_workers_online" in out, "gauge missing from exposition"
print("EXPOSITION_OK")
asyncio.run(main())