Skip to content

Commit 89a1643

Browse files
ogabrielluizseverfireautofix-ci[bot]erichare
authored
feat(job_queue): export cancel_stats and active_jobs to OpenTelemetry (#13123)
* feat: implement Redis job queue and fakeredis support - Refactored the job queue service to support Redis-backed management for cross-worker scaling. - Added environment variables for configuration: - `LANGFLOW_JOB_QUEUE_TYPE=redis` - `LANGFLOW_REDIS_QUEUE_DB=1` - Updated job ownership methods to be asynchronous for improved concurrency handling. - Enhanced Redis cache service with namespacing via key prefixes. - Introduced `fakeredis` for in-memory Redis simulation in testin> - Added comprehensive unit tests for Redis job queue components. * fix: run experimental warning for RedisCache usage only once - Introduced a mechanism to emit a one-time warning for the RedisCache experimental feature during server runtime. - The warning is logged only if no other worker has already emitted it, ensuring clarity for users regarding the experimental status of RedisCache. - The implementation includes a temporary file check to prevent multiple warnings across different processes. * docs: document environment variables for worker management - Added documentation for LANGFLOW_GUNICORN_PRELOAD to explain preloading for better performance. - Detailed the use of LANGFLOW_JOB_QUEUE_TYPE for specifying backends (e.g., Redis). - Included LANGFLOW_REDIS_QUEUE_DB to define the database index for job queues. - Updated the "High-Load Environments" guide with these optimal configurations. * docs: updated 'High-load and multi-worker environments' section * feat: enhance RedisJobQueueService with consumer wrapper management - Introduced a caching mechanism for Redis stream consumers to optimize job data retrieval. - Added methods to manage consumer wrappers, ensuring they are reused across sequential polls. - Implemented cleanup logic to cancel and clear consumer wrappers during job cleanup and service stop. - Expanded unit tests to verify consumer wrapper reuse and cleanup behavior. * fix: ensure Redis keys are deleted during job cleanup even on cancellation - Updated the cleanup_job method in RedisJobQueueService to guarantee Redis keys are removed even if the job cleanup is interrupted by a CancelledError. - Added a new unit test to verify that Redis keys are deleted correctly when cleanup is called during task cancellation. * fix: manage connection check task in RedisJobQueueService - Added handling for the connection check task in the stop method to ensure it is properly cancelled and awaited if still running. - This change improves resource management and prevents potential issues during service shutdown. * fix: handle unpublished sentinel requeue on cancellation in RedisJobQueueService - Updated the job processing logic to ensure that if a job is cancelled during the xadd operation, the unpublished sentinel is requeued instead of being dropped. - Introduced a new unit test to verify this behavior, ensuring robustness in job handling during cancellations. * fix: improve Redis job queue service with enhanced configuration and cleanup - Added atexit cleanup to remove stale temporary files for RedisCache. - Refactored Redis job queue service to use shared constants for stream prefixes, improving maintainability. - Updated type hints for better clarity and consistency in RedisQueueWrapper and RedisJobQueueService. - Enhanced error handling with configurable backoff for transient read failures. * fix: enhance Redis job queue service with maxlen configuration for xadd - Updated the xadd method in RedisJobQueueService to include maxlen and approximate parameters, improving stream management and preventing excessive memory usage. * fix: enhance job ownership retrieval in RedisJobQueueService - Updated the get_job_owner method to refresh the Redis key TTL on successful lookups, ensuring long-running jobs maintain their ownership anchor. - Improved code clarity by extracting the owner key into a variable and adding detailed docstring explanations for better understanding of the TTL management. * fix: improve job ownership handling in cleanup_job method - Enhanced the cleanup_job method in RedisJobQueueService to accurately capture job ownership before deleting Redis keys, preventing potential data corruption in multi-worker scenarios. - Added comments for clarity on ownership logic and its implications during job cleanup. * fix: optimize TTL management in RedisJobQueueService - Introduced periodic TTL refresh logic in the _bridge_to_redis method to enhance Redis stream management, reducing round-trips and improving throughput. - Added constants for TTL refresh events and seconds to maintain clarity and configurability. - Updated event handling to ensure TTL is refreshed appropriately based on event count and time elapsed. * fix: improve event handling in flow response management - Removed unnecessary error handling for missing event tasks in get_flow_events_response, allowing for smoother operation when no task exists. - Updated create_flow_response to handle optional event_task parameter, ensuring proper cleanup during disconnections. - Added unit tests to verify behavior when event tasks are missing, enhancing robustness in streaming scenarios. * fix: enhance RedisQueueWrapper with startup grace period and stream observation - Added a startup grace period to prevent premature end-of-stream signals when the producer has not yet issued its first XADD. - Introduced a flag to track whether the stream has been observed, improving the handling of early polling scenarios. - Updated logic to ensure proper handling of stream existence checks and logging for better debugging during job processing. * fix: implement cleanup for old cross-worker job queues in RedisJobQueueService - Added a new method to clean up done cross-worker consumer wrappers that are not owned by the current worker, ensuring proper resource management. - Enhanced the existing cleanup logic to prevent memory leaks by explicitly pruning stale entries from the consumer wrappers dictionary. - Improved logging to provide better visibility into the cleanup process for cross-worker jobs. * fix: enhance job handling in JobQueueService with guarded task execution - Updated the start_job method to accept a Coroutine type for task_coro, ensuring type safety. - Introduced a new _guarded_task method to wrap job coroutines, guaranteeing that unhandled exceptions emit an error event and write a sentinel to the Redis Stream, improving reliability in job processing. - Enhanced documentation to clarify the behavior of the new task handling mechanism and its implications for cross-worker consumers. * fix: improve client disconnection handling in create_flow_response - Added logging for scenarios where a client disconnects without an associated event_task, clarifying that the producer will continue running until the build completes. - Documented the limitation regarding cross-worker passive disconnects and the need for a Redis side-channel for proper cancellation, enhancing observability in the logs. * fix: enhance RedisQueueWrapper to manage initial read state and buffer behavior - Introduced a flag to track the completion of the first XREAD call, ensuring proper buffer management during the initial read phase. - Updated the empty method to reflect the state of the buffer accurately, preventing premature exits from the drain loop until the first read is complete. - Improved documentation to clarify the behavior of the new flag and its impact on job processing. * fix: clarify RedisQueueWrapper behavior in tests for buffer state and no-op operations - Updated test documentation to explain the behavior of the empty() method in relation to the first XREAD completion, ensuring accurate understanding of buffer state during job processing. - Adjusted assertions in tests to reflect the intended behavior of the RedisQueueWrapper, specifically regarding the no-op nature of put_nowait and its impact on the internal buffer. * fix: update dependencies in pyproject.toml and uv.lock - Added fakeredis dependency with a minimum version of 2.0.0 to both pyproject.toml and uv.lock files. - Ensured proper formatting and comments in pyproject.toml for clarity on onnxruntime version constraints. * feat(job_queue): cross-worker cancel via Redis pub/sub + configurable startup grace - Add LANGFLOW_REDIS_QUEUE_STARTUP_GRACE_S (default 30.0) so deployments with slow producer-worker cold-start can bump the wrapper's early-poll grace period without editing code. - Add LANGFLOW_REDIS_QUEUE_CANCEL_CHANNEL_ENABLED (default True) wiring a per-job Redis pub/sub channel (langflow:cancel:<job_id>) so POST /build/{job_id}/cancel works cross-worker. The producer subscribes when the job starts; any worker can publish via RedisJobQueueService.signal_cancel and the subscriber cancels the local build task on receipt. - cancel_flow_build now falls back to signal_cancel when event_task is None on this worker, replacing the previous no-op for cross-worker cancellation. - Subscriber tasks are tracked in _cancel_subscribers and torn down in cleanup_job/stop alongside the bridge tasks. - Tests: 3 new unit tests covering cross-worker signal propagation, the disabled-flag no-op, and the wrapper's per-instance startup_grace_s override. * fix(job_queue): close three cross-worker cancel corner cases Discovered by a deeper local stress pass over the prototype: 1. **Signal-before-subscribe race**: publish from worker A would be lost if worker B's subscriber had not finished SUBSCRIBE yet. signal_cancel now also sets a short-lived langflow:cancel-marker:<job_id> key, and the subscriber checks it immediately after subscribing — so a cancel that raced the SUBSCRIBE still fires. 2. **Restart subscriber leak**: start_job called twice for the same job_id silently overwrote the dict entry and orphaned the previous subscriber (and its Redis pubsub connection). Now cancels the previous subscriber before storing the new one. 3. **Slow end-of-stream after cross-worker cancel**: cancelling the local task did not unblock cross-worker consumers — the bridge sat waiting on local_queue.get() until periodic cleanup ran ~5 min later. The subscriber now puts a sentinel on the local queue after task.cancel() so the bridge flushes a clean end-of-stream marker to Redis. Measured 8ms from signal_cancel to consumer end-of-stream against real Redis. Tests: 3 new unit tests covering each corner case; 9-scenario local stress harness against real Redis confirms all green. * refactor(job_queue): address self-review of cross-worker cancel Addresses the eight points from the PR review of the previous commits: 1. Configurable marker TTL — new LANGFLOW_REDIS_QUEUE_CANCEL_MARKER_TTL setting (default 60s) replaces the hardcoded constant. 2. Connection-pool footprint is O(1) in active jobs — replaced the per-job pub/sub subscriber with a single PSUBSCRIBE dispatcher per worker listening on langflow:cancel:*. 3. Prompt stream cleanup after cancel — the dispatcher now waits for the bridge to drain the sentinel before triggering cleanup_job, so the Redis stream + owner keys are deleted within milliseconds instead of waiting for the 5-minute periodic cleanup grace. 4. signal_cancel raises on Redis failure — publish errors propagate to the caller instead of silently returning 0. The cancel HTTP endpoint catches and returns False so the client can retry. 5. Auth note in signal_cancel docstring — explicit note that callers must verify authorization; the HTTP cancel endpoint already does via _verify_job_ownership before calling through. 6. Structured cancel logging — INFO-level logs on publish, marker hit, owned dispatch, foreign dispatch. _cancel_stats counters expose {published, marker_hit, dispatched_owned, dispatched_foreign, publish_errors} for ops/metrics. 7. redis-py version compatibility — _close_pubsub falls back from aclose() to close(), handles both sync and async return values. 8. Fire-and-forget tasks hold strong references — _background_tasks set keeps marker checks and post-cancel cleanups from being GC'd before they run; each task self-removes via add_done_callback. stop() drains the set on shutdown. Tests: 24 passing (1 skipped due to timing); deep stress harness verified 6 scenarios against real Redis. * feat(job_queue): propagate client disconnect via signal_cancel for cross-worker builds Closes the remaining cross-worker passive-disconnect gap. Previously, when a client closed its streaming connection on a non-owner worker, the producer worker kept emitting events until the build completed naturally. Now the disconnect handler in create_flow_response publishes a cross-worker cancel via signal_cancel so the owning worker stops promptly. - create_flow_response accepts optional queue_service + job_id kwargs and uses them in on_disconnect for the cross-worker case (event_task is None). Both are keyword-only and default to None, preserving the single-worker contract. - get_flow_events_response wires both through. - New unit test test_cross_worker_disconnect_publishes_signal_cancel covers the full pubsub propagation path with two services sharing a fake Redis. - Tested end-to-end against real Redis with a two-worker harness: 10/10 scenarios pass, including the new disconnect propagation case. * feat(job_queue): production-harden cross-worker cancel for multi-worker setups Five complementary improvements that make the Redis-backed cancel/lifecycle path safe to leave running unattended under real ops pressure. A. Dispatcher reconnect loop with exponential backoff. _run_cancel_dispatcher used to exit silently on any Redis-side error (broker restart, network blip, listen() hiccup), leaving the worker permanently blind to cross-worker cancels until process restart. The loop now reconnects with capped backoff (max 30s), and a dispatcher_reconnects counter exposes the event for monitoring. B. Outer timeout on _post_cancel_cleanup. The background cleanup task could hang indefinitely if cleanup_job stalled (Redis pathology during DELETE); wrap in asyncio.wait_for and let periodic cleanup retry instead. C. Public metrics_snapshot() on JobQueueService (memory + Redis backends) plus GET /monitor/job_queue endpoint behind get_current_active_user. Surfaces backend, active_jobs, bridge_count, consumer_wrapper_count, background_task_count, cancel_dispatcher_running, and the full cancel_stats counter set. D. Deterministic rewrite of test_redis_service_signal_cancel_flushes_sentinel_to_consumer. The previous version was skipped roughly half the time due to a fakeredis timing race; the new version no-ops cleanup_job for the duration of the assertion so the bridge XADD ordering can be observed reliably. No more skipped tests in the suite (33 pass, 0 skip). E. Polling-mode stale-client watchdog. Polling has no persistent connection for on_disconnect, so abandoned polling builds previously ran to natural completion even after the client gave up. New flow: * touch_activity(job_id) writes langflow:activity:<job_id> on every poll and on streaming-response open; consume_and_yield refreshes it every 10s while the connection is held. * _run_polling_watchdog scans owned jobs every LANGFLOW_REDIS_QUEUE_POLLING_WATCHDOG_INTERVAL_S (default 15s) and publishes signal_cancel for jobs whose activity is older than LANGFLOW_REDIS_QUEUE_POLLING_STALE_THRESHOLD_S (default 90s). * Streaming clients are protected by the heartbeat refresh; threshold <= 0 disables the watchdog entirely. * cleanup_job folds the activity key into the existing DEL round-trip so successful builds clean up after themselves. End-to-end coverage: 33 unit tests pass (up from 25, with the previous skip now passing deterministically); a two-worker real-Redis harness exercises 12 scenarios including dispatcher reconnect, post-cancel timeout, watchdog reclamation, and metrics_snapshot schema completeness. * fix(job_queue): address PR review + Codex adversarial review findings Acts on every must-fix finding from a multi-agent review pass (code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer) plus the Codex adversarial review. No behavior is left to chance under realistic operational conditions. Critical fixes: - Streaming heartbeat now runs as an INDEPENDENT task for the lifetime of the response, not coupled to event yield cadence. A quiet build (long graph step, slow LLM, no tokens for a while) previously had no heartbeat path, so the polling watchdog could reclaim a live streaming client after the threshold. The new heartbeat fires every streaming_activity_refresh_s regardless of queue activity, and is cancelled cleanly on both on_disconnect AND natural stream end. - Watchdog start-grace window. A new in-memory _job_start_times[job_id] timestamp is set in start_job() BEFORE super().start_job() so a job can never appear in self._queues without a corresponding start time. When the watchdog scans and the Redis activity key is missing, it skips the kill until (now - start_ts) >= threshold. Without this, a slow background touch_activity (event-loop pressure, Redis blip) could nuke a brand-new build on the very first watchdog tick — the prior code's comment promised the guard but didn't implement it. - touch_activity errors are now observable. Replaces a blanket contextlib.suppress(Exception) with an explicit try/except that bumps activity_touch_errors and emits a debug log. CancelledError is no longer swallowed. Operators get a signal when the heartbeat is silently failing. - Watchdog UnboundLocalError closed. Initializes last = 0.0 before the raw-is-None branch so a malformed activity value (parse failure) can't crash the entire watchdog task. Adds activity_parse_errors and activity_get_errors counters. - Dispatcher except is split: ConnectionError/TimeoutError/OSError stay at awarning (transient Redis), every other Exception goes to aerror with exc_info=True AND increments dispatcher_internal_errors. Code bugs in _handle_cancel no longer look identical to Redis disconnects. - _post_cancel_cleanup narrows the bridge-wait suppress to TimeoutError only, with a dedicated awarning naming the consequence ("cross-worker consumers may see late end-of-stream"). Real bridge failures bubble up via a separate Exception arm with awarning instead of being silent. - Polling watchdog uses local _handle_cancel for owned jobs instead of round-tripping through pubsub. Faster (no Redis RTT), dispatcher- independent (works during a reconnect backoff), and keeps the cancel_stats["published"] counter honest as a count of *external* cancels. - /monitor/job_queue gated to get_current_active_superuser instead of get_current_active_user — the snapshot exposes process-wide tenant workload + cancel activity, which matters in multi-tenant deployments. Docstring corrections: - Removed the "cross-worker cancel is a known limitation" remnant from RedisJobQueueService.get_queue_data — the limitation was closed in the prior commit, and the new docstring tells callers exactly how cross-worker cancel travels (signal_cancel → dispatcher). - touch_activity docstring rewritten. The previous text described a "TTL-based reasoning" fallback that didn't exist; the new text correctly explains the 4x-threshold TTL and how it interacts with the watchdog's start-time grace window. New tests (5 added, total 38 pass): - test_polling_watchdog_grants_start_grace_window: brand-new job with deleted activity key survives until the threshold passes, then dies. - test_polling_watchdog_skips_malformed_activity_value: malformed value bumps activity_parse_errors and does NOT kill the job. - test_streaming_heartbeat_runs_independent_of_event_yield: quiet streaming build is not reclaimed; heartbeat task is cancelled on disconnect. - test_concurrent_cancels_from_multiple_workers_are_idempotent: two workers publish cancel simultaneously, no crashes, single observable cancel. - test_dispatcher_internal_error_logged_at_error_level: bug in _handle_cancel bumps dispatcher_internal_errors and dispatcher_reconnects; the dispatcher task is still running afterwards. Plus: test_metrics_snapshot_exposes_cancel_stats_and_counters now pins the full cancel_stats key set (using set equality), so adding an increment without registering the key — or vice versa — fails the test instead of producing a silent KeyError in production. End-to-end coverage: 38 unit tests pass; the two-worker real-Redis harness passes all 12 scenarios on commit HEAD. * fix: harden redis job queue cancellation * feat(job_queue): export cancel_stats and active_jobs to OpenTelemetry Wire the new Redis job queue counters into the existing langflow OTel instrumentation so they flow to the Prometheus exporter when LANGFLOW_PROMETHEUS_ENABLED=true. Until now cancel_stats lived only in the JSON /monitor/job_queue snapshot — fine for ad-hoc inspection, useless for ongoing ops dashboards or alerting. Two new metrics are registered: * langflow_job_queue_cancel_events_total (Counter, label: event_type) — one Counter per distinct event_type covering all eleven cancel_stats keys (published, marker_hit, dispatched_owned, dispatched_foreign, publish_errors, dispatcher_reconnects, dispatcher_internal_errors, polling_watchdog_kills, activity_touch_errors, activity_get_errors, activity_parse_errors). * langflow_job_queue_active_jobs (UpDownCounter, label: backend) — bumped +1 in JobQueueService.create_queue and -1 in cleanup_job, so both the in-memory ("memory") and Redis ("redis") backends report. A single _bump_cancel_stat helper on RedisJobQueueService is now the sole mutation point for the cancel_stats dict, guaranteeing the JSON snapshot and Prometheus stay in lockstep. The OTel emit path is best-effort: a cached lazy resolver fetches the telemetry singleton once, and contextlib.suppress wraps the emit so a broken or unavailable telemetry layer never propagates into the queue hot path. Tests cover dict + counter parity, active_jobs delta on create/cleanup, silent failure when telemetry is unavailable, and that every cancel_stats key routes through the helper. * [autofix.ci] apply automated fixes * test: update telemetry metric expectations * fix: export job queue metrics to Prometheus --------- Co-authored-by: Arek Mateusiak <arek@a4bits.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Hare <ericrhare@gmail.com>
1 parent b9d207e commit 89a1643

4 files changed

Lines changed: 251 additions & 21 deletions

File tree

src/backend/base/langflow/services/job_queue/service.py

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ class JobQueueService(Service):
129129

130130
name = "job_queue_service"
131131

132+
# Backend label used on OpenTelemetry metrics. Subclasses override.
133+
_backend_label: str = "memory"
134+
132135
def __init__(self) -> None:
133136
"""Initialize the JobQueueService.
134137
@@ -142,6 +145,44 @@ def __init__(self) -> None:
142145
self._closed = False
143146
self.ready = False
144147
self.CLEANUP_GRACE_PERIOD = 300 # 5 minutes before cleaning up marked tasks
148+
# Cached OpenTelemetry handle (resolved lazily on first emit).
149+
# _otel_resolved is True after the first lookup attempt, including failures,
150+
# so we don't retry on every bump. _otel remains None when lookup fails.
151+
self._otel: Any = None
152+
self._otel_resolved: bool = False
153+
154+
def _get_otel(self) -> Any:
155+
"""Best-effort lookup of the OpenTelemetry singleton. Cached after first call.
156+
157+
Returns the ``ot`` handle, or ``None`` if telemetry is unavailable. Never raises —
158+
instrumentation must not break the queue.
159+
"""
160+
if self._otel_resolved:
161+
return self._otel
162+
self._otel_resolved = True
163+
try:
164+
from langflow.services.deps import get_telemetry_service
165+
166+
self._otel = get_telemetry_service().ot
167+
except Exception: # noqa: BLE001 - telemetry must not crash the queue
168+
self._otel = None
169+
return self._otel
170+
171+
def _emit_otel_counter(self, metric_name: str, labels: dict[str, str], value: float = 1.0) -> None:
172+
"""Best-effort OpenTelemetry counter bump. Silent on failure."""
173+
ot = self._get_otel()
174+
if ot is None:
175+
return
176+
with contextlib.suppress(Exception):
177+
ot.increment_counter(metric_name, labels, value)
178+
179+
def _emit_otel_up_down(self, metric_name: str, value: float, labels: dict[str, str]) -> None:
180+
"""Best-effort OpenTelemetry up-down counter delta. Silent on failure."""
181+
ot = self._get_otel()
182+
if ot is None:
183+
return
184+
with contextlib.suppress(Exception):
185+
ot.up_down_counter(metric_name, value, labels)
145186

146187
def is_started(self) -> bool:
147188
"""Check if the JobQueueService has started.
@@ -241,6 +282,7 @@ def create_queue(self, job_id: str) -> tuple[asyncio.Queue, EventManager]:
241282

242283
# Register the queue without an active task.
243284
self._queues[job_id] = (main_queue, event_manager, None, None)
285+
self._emit_otel_up_down("langflow_job_queue_active_jobs", 1, {"backend": self._backend_label})
244286
logger.debug(f"Queue and event manager successfully created for job_id {job_id}")
245287
return main_queue, event_manager
246288

@@ -436,7 +478,8 @@ async def cleanup_job(self, job_id: str) -> None:
436478

437479
await logger.adebug(f"Removed {items_cleared} items from queue for job_id {job_id}")
438480
# Remove the job entry from the registry
439-
self._queues.pop(job_id, None)
481+
if self._queues.pop(job_id, None) is not None:
482+
self._emit_otel_up_down("langflow_job_queue_active_jobs", -1, {"backend": self._backend_label})
440483
self._job_owners.pop(job_id, None)
441484
self._public_jobs.discard(job_id)
442485
await logger.adebug(f"Cleanup successful for job_id {job_id}: resources have been released.")
@@ -810,6 +853,8 @@ class RedisJobQueueService(JobQueueService):
810853
ACTIVITY_PREFIX = _ACTIVITY_PREFIX
811854
PUBLIC_JOB_PREFIX = _PUBLIC_JOB_PREFIX
812855

856+
_backend_label: str = "redis"
857+
813858
def __init__(
814859
self,
815860
host: str = "localhost",
@@ -880,6 +925,16 @@ def cross_worker_cancel_enabled(self) -> bool:
880925
"""
881926
return self._cancel_channel_enabled
882927

928+
def _bump_cancel_stat(self, key: str, value: int = 1) -> None:
929+
"""Increment a cancel_stat counter and emit the matching OTel counter event.
930+
931+
Single point of mutation for ``self._cancel_stats`` so ``/monitor/job_queue``
932+
and Prometheus stay in lockstep. The OTel emit is best-effort and never
933+
raises (see :meth:`_emit_otel_counter` on the base class).
934+
"""
935+
self._cancel_stats[key] += value
936+
self._emit_otel_counter("langflow_job_queue_cancel_events_total", {"event_type": key}, float(value))
937+
883938
def _stream_key(self, job_id: str) -> str:
884939
return f"{self.STREAM_PREFIX}{job_id}"
885940

@@ -1208,7 +1263,7 @@ async def touch_activity(self, job_id: str) -> None:
12081263
except asyncio.CancelledError:
12091264
raise
12101265
except Exception as exc: # noqa: BLE001
1211-
self._cancel_stats["activity_touch_errors"] += 1
1266+
self._bump_cancel_stat("activity_touch_errors")
12121267
await logger.adebug(f"touch_activity SET failed for {job_id}: {exc}")
12131268

12141269
def _owner_refresh_interval_s(self) -> float:
@@ -1248,7 +1303,7 @@ async def _check_pending_cancel_marker(self, job_id: str) -> None:
12481303
try:
12491304
if await self._client.exists(marker_key):
12501305
await self._client.delete(marker_key)
1251-
self._cancel_stats["marker_hit"] += 1
1306+
self._bump_cancel_stat("marker_hit")
12521307
await self._handle_cancel(job_id, source="marker")
12531308
except Exception as exc: # noqa: BLE001
12541309
await logger.awarning(f"Pending cancel marker check failed for {job_id}: {exc}")
@@ -1306,7 +1361,7 @@ async def _run_polling_watchdog(self) -> None:
13061361
try:
13071362
raw = await self._client.get(self._activity_key(job_id))
13081363
except Exception as exc: # noqa: BLE001
1309-
self._cancel_stats["activity_get_errors"] += 1
1364+
self._bump_cancel_stat("activity_get_errors")
13101365
await logger.adebug(f"polling watchdog: GET failed for {job_id}: {exc}")
13111366
continue
13121367
# Default to "infinitely stale" so a missed elif below cannot
@@ -1324,7 +1379,7 @@ async def _run_polling_watchdog(self) -> None:
13241379
try:
13251380
last = float(raw.decode() if isinstance(raw, bytes) else raw)
13261381
except (ValueError, TypeError) as exc:
1327-
self._cancel_stats["activity_parse_errors"] += 1
1382+
self._bump_cancel_stat("activity_parse_errors")
13281383
await logger.awarning(
13291384
f"polling watchdog: ignoring malformed activity value for {job_id}: {exc}"
13301385
)
@@ -1335,7 +1390,7 @@ async def _run_polling_watchdog(self) -> None:
13351390
# Stale → cancel this job. Local cancel on owned jobs skips the
13361391
# pubsub round-trip and stays correct even during a dispatcher
13371392
# reconnect window.
1338-
self._cancel_stats["polling_watchdog_kills"] += 1
1393+
self._bump_cancel_stat("polling_watchdog_kills")
13391394
await logger.ainfo(f"polling watchdog: reclaiming abandoned job {job_id} (age={age:.1f}s)")
13401395
await self._handle_cancel(job_id, source="watchdog")
13411396
with contextlib.suppress(Exception):
@@ -1383,7 +1438,7 @@ async def _run_cancel_dispatcher(self) -> None:
13831438
job_id = channel_str[len(self.CANCEL_CHANNEL_PREFIX) :]
13841439
await self._handle_cancel(job_id, source="pubsub")
13851440
# listen() returned cleanly — treat as a soft disconnect and reconnect.
1386-
self._cancel_stats["dispatcher_reconnects"] += 1
1441+
self._bump_cancel_stat("dispatcher_reconnects")
13871442
await logger.awarning(f"Cancel dispatcher pubsub.listen() ended; reconnecting in {backoff:.1f}s")
13881443
except asyncio.CancelledError:
13891444
with contextlib.suppress(Exception):
@@ -1393,15 +1448,15 @@ async def _run_cancel_dispatcher(self) -> None:
13931448
except (ConnectionError, TimeoutError, OSError) as exc:
13941449
# Expected transient failure: Redis dropped the pubsub, network
13951450
# blip, broker restart. Reconnect quietly via the backoff loop.
1396-
self._cancel_stats["dispatcher_reconnects"] += 1
1451+
self._bump_cancel_stat("dispatcher_reconnects")
13971452
await logger.awarning(f"Cancel dispatcher disconnect (retrying in {backoff:.1f}s): {exc!r}")
13981453
except Exception as exc: # noqa: BLE001
13991454
# Unexpected exception — likely a bug in dispatch logic, NOT a
14001455
# Redis problem. Surface at error level with traceback so it
14011456
# reaches Sentry / log aggregation, then still reconnect so a
14021457
# one-off bug doesn't kill cross-worker cancel permanently.
1403-
self._cancel_stats["dispatcher_reconnects"] += 1
1404-
self._cancel_stats["dispatcher_internal_errors"] += 1
1458+
self._bump_cancel_stat("dispatcher_reconnects")
1459+
self._bump_cancel_stat("dispatcher_internal_errors")
14051460
await logger.aerror(
14061461
f"Cancel dispatcher internal error (retrying in {backoff:.1f}s): {exc!r}",
14071462
exc_info=True,
@@ -1418,7 +1473,7 @@ async def _run_cancel_dispatcher(self) -> None:
14181473

14191474
async def _on_cancel_dispatcher_connection_reconnect(self, _connection: Any) -> None:
14201475
"""Record redis-py reconnects that happen inside an active PubSub."""
1421-
self._cancel_stats["dispatcher_reconnects"] += 1
1476+
self._bump_cancel_stat("dispatcher_reconnects")
14221477
with contextlib.suppress(Exception):
14231478
await logger.awarning("Cancel dispatcher pubsub connection reconnected transparently")
14241479

@@ -1458,10 +1513,10 @@ async def _apply_cancel(self, job_id: str, *, source: str, wait_for_cleanup: boo
14581513
"""
14591514
entry = self._queues.get(job_id)
14601515
if entry is None:
1461-
self._cancel_stats["dispatched_foreign"] += 1
1516+
self._bump_cancel_stat("dispatched_foreign")
14621517
await logger.adebug(f"Cancel for {job_id} ignored on this worker (not owner); source={source}")
14631518
return
1464-
self._cancel_stats["dispatched_owned"] += 1
1519+
self._bump_cancel_stat("dispatched_owned")
14651520
await logger.ainfo(f"Cancel applied to {job_id} (source={source})")
14661521
main_queue, _, task, _ = entry
14671522
if task is not None and not task.done():
@@ -1569,9 +1624,9 @@ async def signal_cancel(self, job_id: str) -> int:
15691624
await self._client.set(self._cancel_marker_key(job_id), "1", ex=self._cancel_marker_ttl)
15701625
receivers = int(await self._client.publish(self._cancel_channel(job_id), "1"))
15711626
except Exception:
1572-
self._cancel_stats["publish_errors"] += 1
1627+
self._bump_cancel_stat("publish_errors")
15731628
raise
1574-
self._cancel_stats["published"] += 1
1629+
self._bump_cancel_stat("published")
15751630
await logger.ainfo(f"signal_cancel: job_id={job_id} receivers={receivers}")
15761631
return receivers
15771632

src/backend/base/langflow/services/telemetry/opentelemetry.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,25 @@ def _register_metric(self) -> None:
140140
metric_type=MetricType.COUNTER,
141141
labels={"flow_id": mandatory_label},
142142
)
143+
self._add_metric(
144+
name="langflow_job_queue_cancel_events_total",
145+
description=(
146+
"Job queue cancel-channel and watchdog events. event_type is one of: "
147+
"published, marker_hit, dispatched_owned, dispatched_foreign, publish_errors, "
148+
"dispatcher_reconnects, dispatcher_internal_errors, polling_watchdog_kills, "
149+
"activity_touch_errors, activity_get_errors, activity_parse_errors."
150+
),
151+
unit="",
152+
metric_type=MetricType.COUNTER,
153+
labels={"event_type": mandatory_label},
154+
)
155+
self._add_metric(
156+
name="langflow_job_queue_active_jobs",
157+
description="Active jobs tracked by the job queue on this worker.",
158+
unit="",
159+
metric_type=MetricType.UP_DOWN_COUNTER,
160+
labels={"backend": mandatory_label},
161+
)
143162

144163
def __init__(self, *, prometheus_enabled: bool = True):
145164
# Only initialize once
@@ -154,8 +173,10 @@ def __init__(self, *, prometheus_enabled: bool = True):
154173
# Get existing meter provider if any
155174
existing_provider = metrics.get_meter_provider()
156175

157-
# Check if FastAPI instrumentation is already set up
158-
if hasattr(existing_provider, "get_meter") and existing_provider.get_meter("http.server"):
176+
# Reuse a concrete SDK provider installed by another integration. The
177+
# default API proxy also returns meters, but it has no readers and must
178+
# be replaced so Prometheus can collect Langflow metrics.
179+
if isinstance(existing_provider, MeterProvider):
159180
self._meter_provider = existing_provider
160181
else:
161182
resource = Resource.create({"service.name": "langflow"})

src/backend/tests/unit/test_redis_job_queue_service.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2904,3 +2904,119 @@ async def test_initialize_services_fails_fast_when_redis_queue_unreachable(monke
29042904
finally:
29052905
manager.factories.clear()
29062906
manager.services.clear()
2907+
2908+
2909+
# ---------------------------------------------------------------------------
2910+
# OpenTelemetry instrumentation
2911+
# ---------------------------------------------------------------------------
2912+
2913+
2914+
class _OtelRecorder:
2915+
"""Capture OTel emissions so tests can assert what the queue exported.
2916+
2917+
Substituted for the real OT singleton via ``service._otel``. Mirrors the
2918+
surface area used by ``_emit_otel_counter`` and ``_emit_otel_up_down``.
2919+
"""
2920+
2921+
def __init__(self) -> None:
2922+
self.counters: list[tuple[str, dict[str, str], float]] = []
2923+
self.up_downs: list[tuple[str, float, dict[str, str]]] = []
2924+
2925+
def increment_counter(self, name: str, labels: dict[str, str], value: float = 1.0) -> None:
2926+
self.counters.append((name, dict(labels), value))
2927+
2928+
def up_down_counter(self, name: str, value: float, labels: dict[str, str]) -> None:
2929+
self.up_downs.append((name, value, dict(labels)))
2930+
2931+
2932+
def _attach_recorder(service: Any) -> _OtelRecorder:
2933+
"""Bypass the lazy OTel resolver and inject a recorder."""
2934+
recorder = _OtelRecorder()
2935+
service._otel = recorder
2936+
service._otel_resolved = True
2937+
return recorder
2938+
2939+
2940+
@pytest.mark.asyncio
2941+
async def test_bump_cancel_stat_updates_dict_and_otel_counter():
2942+
service, _client = await _make_service(cancel_channel_enabled=False)
2943+
recorder = _attach_recorder(service)
2944+
try:
2945+
service._bump_cancel_stat("published")
2946+
service._bump_cancel_stat("marker_hit", value=2)
2947+
2948+
# Dict mirror still works for /monitor/job_queue.
2949+
assert service._cancel_stats["published"] == 1
2950+
assert service._cancel_stats["marker_hit"] == 2
2951+
2952+
# OTel counter received both bumps with event_type label.
2953+
assert (
2954+
"langflow_job_queue_cancel_events_total",
2955+
{"event_type": "published"},
2956+
1.0,
2957+
) in recorder.counters
2958+
assert (
2959+
"langflow_job_queue_cancel_events_total",
2960+
{"event_type": "marker_hit"},
2961+
2.0,
2962+
) in recorder.counters
2963+
finally:
2964+
await _stop_service(service)
2965+
2966+
2967+
@pytest.mark.asyncio
2968+
async def test_create_and_cleanup_move_active_jobs_up_down_counter():
2969+
service, _client = await _make_service(cancel_channel_enabled=False)
2970+
recorder = _attach_recorder(service)
2971+
try:
2972+
job_id = uuid.uuid4().hex
2973+
service.create_queue(job_id)
2974+
await service.cleanup_job(job_id)
2975+
2976+
deltas = [
2977+
(value, labels) for name, value, labels in recorder.up_downs if name == "langflow_job_queue_active_jobs"
2978+
]
2979+
assert (1, {"backend": "redis"}) in deltas
2980+
assert (-1, {"backend": "redis"}) in deltas
2981+
finally:
2982+
await _stop_service(service)
2983+
2984+
2985+
@pytest.mark.asyncio
2986+
async def test_otel_emit_is_silent_when_telemetry_unavailable():
2987+
"""A broken OT handle must never propagate out of the emit helpers."""
2988+
service, _client = await _make_service(cancel_channel_enabled=False)
2989+
try:
2990+
explosion = "telemetry exploded"
2991+
2992+
class _BrokenOt:
2993+
def increment_counter(self, *_a, **_kw):
2994+
raise RuntimeError(explosion)
2995+
2996+
def up_down_counter(self, *_a, **_kw):
2997+
raise RuntimeError(explosion)
2998+
2999+
service._otel = _BrokenOt()
3000+
service._otel_resolved = True
3001+
3002+
# These must not raise even though OT itself does.
3003+
service._bump_cancel_stat("published")
3004+
service._emit_otel_up_down("langflow_job_queue_active_jobs", 1, {"backend": "redis"})
3005+
# Dict mirror still updated despite OT failure.
3006+
assert service._cancel_stats["published"] == 1
3007+
finally:
3008+
await _stop_service(service)
3009+
3010+
3011+
@pytest.mark.asyncio
3012+
async def test_all_cancel_stat_keys_route_through_helper():
3013+
"""Every key initialized in _cancel_stats must be a valid argument to _bump_cancel_stat."""
3014+
service, _client = await _make_service(cancel_channel_enabled=False)
3015+
recorder = _attach_recorder(service)
3016+
try:
3017+
for key in list(service._cancel_stats):
3018+
service._bump_cancel_stat(key)
3019+
emitted_event_types = {labels["event_type"] for _, labels, _ in recorder.counters}
3020+
assert emitted_event_types == set(service._cancel_stats.keys())
3021+
finally:
3022+
await _stop_service(service)

0 commit comments

Comments
 (0)