Skip to content

perf(telemetry): batched off-pool writer for transactions + vertex_builds - #13126

Merged
ogabrielluiz merged 17 commits into
release-1.10.0from
perf/telemetry-writer
Jun 5, 2026
Merged

perf(telemetry): batched off-pool writer for transactions + vertex_builds#13126
ogabrielluiz merged 17 commits into
release-1.10.0from
perf/telemetry-writer

Conversation

@ogabrielluiz

@ogabrielluiz ogabrielluiz commented May 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Heavy load on the transaction and vertex_build tables saturates the request-handling DB pool and triggers row-level deadlocks during retention, causing:

  • SQLite: database is locked errors
  • Postgres: QueuePool ... timed out AND psycopg.errors.DeadlockDetected on the per-row retention DELETE

Today the workaround is LANGFLOW_TRANSACTIONS_STORAGE_ENABLED=false in production, which loses all execution history.

Even though the writes are dispatched off the request path via BackgroundTasks, each background task still acquires a connection from the same pool the request handlers use, plus runs an INSERT + 1-2 retention DELETEs per row. The per-row DELETE-older query is what produces the Postgres deadlocks under concurrent load — many transactions racing for locks on the same older rows.

Approach

New TelemetryWriterService:

  • Producers (log_transaction, log_vertex_build) enqueue rows into an in-memory collections.deque instead of opening a DB session.
  • One background task batches the buffer into bulk INSERTs via a dedicated AsyncEngine (pool_size=1 SQLite, 2 Postgres, max_overflow=0). That pool is structurally isolated — it cannot starve the request pool under any load.
  • A second task runs retention every 60s (same max_transactions_to_keep / max_vertex_builds_to_keep / max_vertex_builds_per_vertex caps, just amortized). Retention runs once per cycle on one connection — deadlock is structurally impossible.
  • Durability: rows still in memory at shutdown spill to a per-PID SQLite outbox (outbox.sqlite, WAL mode, JSON payloads in a TEXT column, PRAGMA synchronous=FULL on the spill connection); on next startup that PID dir is restored and orphan PID dirs from crashed workers are adopted only if their owner.json matches the current host + boot identity.
  • Default-on, gated by LANGFLOW_TELEMETRY_WRITER_ENABLED (legacy direct-write path stays as escape hatch).

Why SQLite instead of diskcache

This branch originally used diskcache.Deque for the on-disk outbox. After review, two reasons to swap:

  1. CVE-2025-69872 (GHSA-w8v5-vhqr-4h9v), MODERATE, no fixed version. diskcache deserializes with pickle by default. An attacker with write access to the cache directory can plant a malicious payload that triggers RCE on read. Our outbox lives under /tmp/langflow_telemetry_outbox/<pid> and we deliberately adopt sibling-PID directories on startup — exactly the vulnerable pattern.
  2. Undeclared dependency. diskcache was not listed in pyproject.toml; it was only resolved transitively for Python 3.11–3.13 via embedchain/unitxt. On Python 3.10 (a supported version) the import fails outright.

Replacement is stdlib sqlite3 in WAL mode with JSON-in-TEXT payloads, encapsulated in a small _Outbox helper:

  • Zero new dependencies.
  • No pickle path anywhere — malformed rows are logged and discarded, not executed.
  • A tagged-wrapper JSON codec preserves datetime and UUID across the round-trip so SQLAlchemy's typed columns accept restored payloads.
  • append_all encodes the whole buffer up front and only clears the deque after the transaction commits, so a mid-flight SQLite failure cannot half-drain the buffer.

Hardening informed by industry practice

A survey of OpenTelemetry Collector, Fluent Bit, Vector, Prometheus remote_write, Datadog agent, Logstash, and Filebeat highlighted three gaps we closed:

  1. Owner identity check before adopting orphan PID dirs. Each PID dir stamps an owner.json at start (hostname + Linux boot_id, or time() - monotonic() as a portable fallback). On adoption we only proceed when host+boot match; cross-host or pre-owner dirs are logged and skipped. This prevents a recycled PID after a container restart from pulling in a stranger's spill data.
  2. PRAGMA synchronous=FULL on the spill connection. NORMAL only fsyncs on WAL checkpoint, which may never run if the process exits immediately after commit. FULL guarantees the shutdown commit hits the platter.
  3. Spill honors telemetry_writer_max_queue with drop-oldest. A backlogged buffer at shutdown can neither stall teardown nor fill disk; the spill cap matches the producer-side overflow policy.

Why this is structurally safe

  • SQLite "database is locked" — SQLite has one writer; the dedicated engine has exactly one connection; producers don't touch SQLite at all. Contention disappears.
  • Postgres pool timeouts — Telemetry writes use a separate 2-conn pool; they cannot consume request-pool capacity.
  • Postgres retention deadlocks — Only one task runs retention, on one connection, every 60s. No concurrent DELETEs to deadlock on.

Baseline: direct stress harness (writer disabled)

200 concurrent worker coroutines, each calling log_transaction + log_vertex_build as fast as possible for 15s. Source: src/backend/tests/stress/stress_telemetry_writes.py.

Backend Total ops Errors Aggregate rate
SQLite (WAL, busy_timeout=30s) 3,212 0 192 ops/s
Postgres 16 (default pool 20+30) 3,274 0 117 ops/s
Postgres 16 (tight pool 5+5, pool_timeout=3s) at 500 workers 3,870 0 194 ops/s

The "0 errors" only means the 30s pool/lock timeouts absorb the contention. The throughput collapse is the actual user-visible failure mode in production.

Results: direct stress harness (writer enabled)

Same harness, same scale, 30s drain grace.

Backend Total ops Errors Aggregate rate Speedup
SQLite 73,128 0 4,875 ops/s 23×
Postgres 16 77,844 0 5,189 ops/s 44×

All rows flushed to DB by end of run; retention caps still enforced by the sweeper.

Results: locust against running Langflow (Postgres 16, Basic Prompting flow)

End-to-end through HTTP /api/v1/run/{flow_id} against a real Langflow backend with the default db_connection_settings (pool_size=20, max_overflow=30). Source: src/backend/tests/locust/locustfile.py.

Writer enabled

Load Requests Failures POST p50 POST p95 POST RPS Pool / lock / deadlock errors
50 users × 60s 521 0 (0.00%) 2.3s 3.8s 8.1 0
200 users × 90s 2,482 2 (0.08%)¹ 3.7s 5.7s 27.7 0
500 users × 90s 2,589 0 (0.00%) 16s 28s 23.6 0

¹ Both failures were locust's own 30s client-side request timeout; not server-side.

Writer disabled (same 500 user / 90s load, head-to-head)

Requests Failures POST p50 POST p95 POST RPS Pool / lock / deadlock errors
Writer OFF 1,844 0 22s 36s 15.0 258 deadlocks
Writer ON 2,589 0 16s 28s 23.6 (+57%) 0

Same flow, same load, same DB. With the writer disabled, 258 psycopg.errors.DeadlockDetected errors fire during the run — all on the transaction table's per-row retention DELETE. Throughput drops 36% and latency rises.

Retention sweeper observed working in steady state: after a full 60s sweep cycle, transaction rows trimmed from ~10k peak to exactly the configured 3000 cap, vertex_build rows trimmed to exactly 200 (4 vertices × 50 max_vertex_builds_per_vertex).

Tradeoffs

  • Hard kill (SIGKILL/OOM) loses in-memory rows. Telemetry is now eventually-consistent. Graceful shutdown spills to disk; next process picks them up. Matches the operational character of these tables (debug logs / execution history, queried interactively).
  • Retention can briefly overshoot the cap between sweeps. Worst case sweep_interval × insert_rate rows per flow above the cap, self-corrects within 60s.
  • New settings (all in lfx/services/settings/base.py): telemetry_writer_enabled (default True), telemetry_writer_batch_size (200), telemetry_writer_flush_interval_s (0.5), telemetry_writer_cleanup_interval_s (60), telemetry_writer_max_queue (100k), telemetry_writer_outbox_dir (None → tmpdir), telemetry_writer_shutdown_drain_s (5.0).

Files

  • New service: src/backend/base/langflow/services/telemetry_writer/{__init__,service,factory}.py
  • New tests: src/backend/tests/unit/services/telemetry_writer/test_service.py (20 end-to-end tests against in-memory SQLite, no mocking of persistence — including spill→restore→INSERT with realistic UUID/datetime payloads, owner-identity skip on cross-host and missing-owner dirs, spill cap drop-oldest, sanitization round-trip, retention failure preservation, and cancel-mid-flush recovery)
  • Stress harness: src/backend/tests/stress/stress_telemetry_writes.py + README (permanent regression test for this failure class)
  • Producer wiring: src/backend/base/langflow/services/transaction/service.py, src/lfx/src/lfx/graph/utils.py
  • Lifespan: src/backend/base/langflow/main.py
  • Plumbing: both ServiceType enums, services/utils.py, services/deps.py, lfx/services/settings/base.py

Observability

When telemetry_writer_enabled=True:

  • Startup logs telemetry_writer started (outbox=..., tx_pending=N, vb_pending=N).
  • If startup fails, ERROR is logged (not warning) — the legacy direct-write path would silently re-engage otherwise.
  • Producer fall-through to legacy emits a one-shot WARNING per process so operators can tell which path is active.
  • After 6 consecutive batch failures the writer emits an ERROR with current buffer depths.
  • Shutdown drain timeout logs a WARNING with the pending row count and a hint to tune telemetry_writer_shutdown_drain_s.

…ilds

Adds TelemetryWriterService that buffers transaction and vertex_build rows
in memory and drains them in batched INSERTs via a dedicated AsyncEngine
(pool_size=1 for SQLite, 2 for Postgres, max_overflow=0). Retention is
amortized in a 60s sweeper instead of running on every insert.

Producers (log_transaction, log_vertex_build) enqueue instead of opening
a DB session, so telemetry traffic no longer competes with the
request-handling pool. Falls back to the legacy direct-write path via
LANGFLOW_TELEMETRY_WRITER_ENABLED=false.

Durability: in-flight rows spill to a diskcache.Deque per PID on shutdown
and are restored on next startup; orphan PID directories left by crashed
workers are adopted.
@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2f20bbff-0d00-426b-b26a-693d5f1e5d48

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 2 warnings)

Check name Status Explanation Resolution
Test Coverage For New Implementations ❌ Error PR includes comprehensive test coverage with 16 unit tests and stress harness. However, two tests contain unbounded PID search loops that can hang CI runs. Extract PID searching into bounded _find_dead_pid() helper with max_checks limit and clear failure path to prevent unbounded loops in test_adopt_orphan_outboxes tests.
Docstring Coverage ⚠️ Warning Docstring coverage is 30.99% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Test Quality And Coverage ⚠️ Warning Tests are good but 3 unresolved review issues remain: unbounded PID loops risk CI hangs, unset env var in README, and dirty-flow race in retention pass. Apply 3 pending fixes: add _find_dead_pid with loop bounds, use concrete Postgres DSN in README, implement capture-and-clear for dirty-flow sets.
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing a batched telemetry writer that decouples transaction and vertex_build writes from the request pool, directly addressing the PR's core objective.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test File Naming And Structure ✅ Passed Unit tests (test_service.py) properly named, follow pytest structure with 16 descriptive tests, async handling, fixtures. Stress test is standalone harness script with documentation as intended.
Excessive Mock Usage Warning ✅ Passed No unittest.mock. 4 justified test doubles: FakeSetting classes for pydantic-settings isolation, 2 inline stubs for error injection. Real SQLite, SQLAlchemy, asyncio tested. Appropriate design.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/telemetry-writer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the performance Maintenance tasks and housekeeping label May 14, 2026
Review feedback from pr-review-toolkit + silent-failure-hunter:

- Retention sweep snapshots the dirty-flow sets before commit and only
  clears them after it lands; a crashed sweep no longer drops the flows on
  the floor, so per-flow caps cannot drift unboundedly.
- Producer fall-through is no longer silent. transaction_service and
  log_vertex_build each log a one-shot WARNING when telemetry_writer_enabled
  is True but the writer is not running.
- Lifespan startup failure now logs ERROR instead of WARNING — if the user
  opted into the writer and it didn't come up, that's an error.
- Shutdown drain timeout no longer suppressed silently: logs a WARNING with
  pending row count + a hint to raise telemetry_writer_shutdown_drain_s.
- Writer's flush loop catches asyncio.CancelledError separately and
  re-prepends the in-flight batch to the buffer so teardown's disk spill
  catches it.
- After 6 consecutive batch failures the writer emits a loud ERROR with
  buffer depths so operators see sustained data-loss risk.

Added tests:
- test_sanitization_survives_writer_round_trip
- test_retention_failure_preserves_dirty_flows
- test_in_flight_batch_returned_on_cancel
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 14, 2026
@dkaushik94

Copy link
Copy Markdown
Member

I was having a few nights' worth of worrying for this one. I added a bit of load to the DB pool exhaustion with the recent features. Thanks for this, I'll review it. nice work @ogabrielluiz ! 👍🏼

@ogabrielluiz

Copy link
Copy Markdown
Contributor Author

@dkaushik94 this still needs some work ahha running the locust tests now.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a new TelemetryWriterService to batch and offload transaction and vertex_build writes onto a dedicated database engine/pool, reducing contention with the request-handling DB pool under heavy load. It also adds disk-backed spill/replay for durability across graceful restarts and provides unit + stress tooling to validate behavior.

Changes:

  • Add TelemetryWriterService (batching writer task + periodic retention sweeper + disk outbox recovery) and register/start it in the backend service lifecycle.
  • Route transaction and vertex_build producers to enqueue into the writer when enabled, falling back to legacy direct DB writes when unavailable.
  • Add unit tests for the writer service and a manual stress harness (plus ruff per-file ignores) to reproduce/benchmark high-concurrency write load.

Reviewed changes

Copilot reviewed 15 out of 17 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/lfx/src/lfx/services/settings/base.py Adds telemetry-writer configuration knobs (enablement, batching, outbox, shutdown drain).
src/lfx/src/lfx/services/schema.py Registers TELEMETRY_WRITER_SERVICE in the LFX service enum.
src/lfx/src/lfx/graph/utils.py Routes log_vertex_build through the telemetry writer when enabled/running, with legacy fallback.
src/backend/base/langflow/services/transaction/service.py Routes log_transaction through telemetry writer when enabled/running, with legacy fallback + one-shot warning.
src/backend/base/langflow/services/telemetry_writer/service.py Implements the new batched writer + sweeper + disk outbox adoption/restore.
src/backend/base/langflow/services/telemetry_writer/factory.py Adds service factory for telemetry writer.
src/backend/base/langflow/services/telemetry_writer/init.py Exposes telemetry writer service/factory module exports.
src/backend/base/langflow/services/utils.py Registers telemetry writer factory with the service manager.
src/backend/base/langflow/services/schema.py Registers TELEMETRY_WRITER_SERVICE in the backend service enum.
src/backend/base/langflow/services/deps.py Adds get_telemetry_writer_service() dependency accessor.
src/backend/base/langflow/main.py Starts the telemetry writer during app lifespan after service initialization.
src/backend/tests/unit/services/telemetry_writer/test_service.py Adds end-to-end unit tests against in-memory SQLite for batching/retention/spill/replay semantics.
src/backend/tests/unit/services/telemetry_writer/init.py Declares the unit test package for telemetry writer tests.
src/backend/tests/stress/stress_telemetry_writes.py Adds manual stress harness for high-concurrency telemetry writes.
src/backend/tests/stress/README.md Documents how to run/interpret the stress harness.
src/backend/tests/stress/init.py Declares the stress test package.
pyproject.toml Adds ruff per-file ignores for the stress harness directory.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/backend/base/langflow/services/telemetry_writer/service.py Outdated
Comment thread src/backend/base/langflow/services/telemetry_writer/service.py
Comment thread src/backend/base/langflow/services/telemetry_writer/service.py Outdated
Comment thread src/backend/base/langflow/services/telemetry_writer/service.py Outdated
Comment thread src/backend/base/langflow/services/telemetry_writer/service.py
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 14, 2026
- _restore_from_disk + _adopt_orphan_outboxes now route through _enqueue so a
  large disk-spilled or orphan outbox can't bypass telemetry_writer_max_queue
  and OOM the process. Oldest rows are dropped and counted via the existing
  dropped_transactions / dropped_vertex_builds counters.
- chmod 0o700 the outbox root + per-PID directory so sanitized-but-still-
  sensitive payloads aren't exposed cross-user on multi-tenant hosts.
  Suppressed on platforms where chmod is a no-op (Windows).
- Added test_adopt_orphan_outboxes_honors_max_queue.

The remaining two copilot notes (private API access to DatabaseService and
diskcache.Cache.close) were also flagged by the in-tree review; tracking
separately. The "diskcache not declared" note is a false positive — the
dependency is at src/backend/base/pyproject.toml:76.
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 14, 2026
@ogabrielluiz
ogabrielluiz marked this pull request as ready for review May 14, 2026 20:04
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/backend/tests/unit/services/telemetry_writer/test_service.py (1)

112-122: ⚡ Quick win

Add short docstrings to tests that currently lack one.

Several test functions here are missing docstrings; adding one-line purpose statements improves failure triage and keeps consistency with the backend test standard.

As per coding guidelines: "Each test should have a clear docstring explaining its purpose; complex test setups should be commented; mock usage should be documented; expected behaviors should be explicitly stated".

Also applies to: 125-133, 144-193, 219-241, 266-295, 323-424

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/telemetry_writer/test_service.py` around
lines 112 - 122, Several test functions (notably test_is_enabled_reads_settings
and test_enqueue_when_not_running_returns_false and other tests in the file
ranges indicated) lack one-line docstrings; add a concise single-line docstring
at the top of each test function describing its purpose (e.g., "Verify writer
reads enabled setting" for test_is_enabled_reads_settings, "Ensure enqueue
returns False when writer not running" for
test_enqueue_when_not_running_returns_false) to comply with the backend test
standard and improve failure triage; apply the same pattern to the other test
functions in the indicated ranges (125-133, 144-193, 219-241, 266-295, 323-424).
src/backend/base/langflow/services/telemetry_writer/service.py (4)

359-376: 💤 Low value

Writer reads batch_size / flush_interval only once at task start.

Both are read at Lines 362–363 and never refreshed, while _run_sweeper re-reads telemetry_writer_cleanup_interval_s every iteration (Line 434) and _enqueue re-reads telemetry_writer_max_queue every call. As a result, runtime changes to these two settings only take effect on a process restart, which is surprising relative to neighboring code. Hoisting the reads inside the loop (or caching them on the service and refreshing on each iteration) would make the behavior uniform.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/telemetry_writer/service.py` around lines
359 - 376, The loop in _run_writer currently reads telemetry_writer_batch_size
and telemetry_writer_flush_interval_s only once at the top, so runtime changes
aren't picked up; move the retrieval of batch_size and flush_interval into the
while True loop (or read them from a cached attribute that is refreshed each
iteration) so they are re-read each iteration from
self.settings_service.settings (consistent with how _run_sweeper and _enqueue
re-check settings). Update _run_writer to obtain batch_size =
int(getattr(self.settings_service.settings, "telemetry_writer_batch_size", 200))
and flush_interval = float(getattr(self.settings_service.settings,
"telemetry_writer_flush_interval_s", 0.5)) inside the loop before using them.

389-407: ⚡ Quick win

Failure-escalation log fires exactly once and then stays silent.

if consecutive_failures == _FAILURE_ESCALATION_THRESHOLD: triggers at exactly 6. If the underlying DB stays broken (or the writer keeps failing for hours), every subsequent failure quietly increments failed_batches and re-applies backoff with no further ERROR signal — defeating the stated purpose of "operators see sustained data-loss risk." Consider either escalating on every Nth failure after the threshold, or upgrading the per-batch log to ERROR once past it:

♻️ Proposed escalation tweak
-                if consecutive_failures == _FAILURE_ESCALATION_THRESHOLD:
-                    logger.error(
-                        f"telemetry_writer: {consecutive_failures} consecutive batch failures, "
-                        f"buffer depth tx={len(self._tx_buffer)} vb={len(self._vb_buffer)}"
-                    )
+                if (
+                    consecutive_failures == _FAILURE_ESCALATION_THRESHOLD
+                    or (
+                        consecutive_failures > _FAILURE_ESCALATION_THRESHOLD
+                        and consecutive_failures % _FAILURE_ESCALATION_THRESHOLD == 0
+                    )
+                ):
+                    logger.error(
+                        f"telemetry_writer: {consecutive_failures} consecutive batch failures, "
+                        f"buffer depth tx={len(self._tx_buffer)} vb={len(self._vb_buffer)}"
+                    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/telemetry_writer/service.py` around lines
389 - 407, The escalation ERROR log only fires once because the code checks if
consecutive_failures == _FAILURE_ESCALATION_THRESHOLD; change this to either
trigger for every failure beyond the threshold (use consecutive_failures >=
_FAILURE_ESCALATION_THRESHOLD) or log at ERROR every Nth failure past the
threshold (e.g., if consecutive_failures >= _FAILURE_ESCALATION_THRESHOLD and
consecutive_failures % N == 0) and optionally promote the per-batch
logger.exception call to logger.error when consecutive_failures is past the
threshold; update the check near the exception handling block that references
consecutive_failures, _FAILURE_ESCALATION_THRESHOLD,
logger.exception/logger.error and failed_batches so operators continue to see
ERRORs while failures persist.

173-192: 💤 Low value

Remove the redundant self._writer_task.cancel() call in the timeout handler.

When asyncio.wait_for() times out, it automatically cancels the inner task before raising TimeoutError. The explicit cancel() at line 187 is a no-op; the subsequent await self._writer_task (with suppress(asyncio.CancelledError)) completes the cancellation. Remove the explicit cancel() to align the timeout path with the explicit-cancel exception handler below it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/telemetry_writer/service.py` around lines
173 - 192, The timeout handler in the shutdown path redundantly calls
self._writer_task.cancel() after asyncio.wait_for(self._writer_task,
timeout=drain_timeout) raises asyncio.TimeoutError (wait_for has already
cancelled the inner task); remove the explicit cancel() call in that except
asyncio.TimeoutError block so the code mirrors the CancelledError handler and
simply awaits self._writer_task inside the suppress(asyncio.CancelledError)
block while keeping the warning log (refer to self._writer_task, drain_timeout,
and the telemetry_writer_shutdown_drain_s setting in the message).

468-526: 🏗️ Heavy lift

Collapse per-flow and per-vertex DELETEs into single windowed query to reduce transaction scope and contention on dedicated SQLite writer connection.

The retention pass issues multiple per-flow DELETEs for transactions and multiple per-(flow, vertex) DELETEs for vertex_builds, all within a single transaction and committed at the end. With pool_size=1 for SQLite, this extends the dedicated writer connection's hold, potentially blocking concurrent writes. In deployments with many flows/vertices, this can result in hundreds of database round-trips and multi-second transaction lock times.

Suggested approach:

  • For transactions, replace the per-flow loop with a single DELETE using a CTE + ROW_NUMBER() OVER (PARTITION BY flow_id ORDER BY timestamp DESC), keeping only ranks ≤ max_transactions.
  • Apply the same pattern for vertex_builds with PARTITION BY flow_id, id.
  • Remove the inner SELECT DISTINCT id loop once the per-(flow, vertex) DELETE is windowed.
  • Window functions are already used in Langflow migrations (e.g., recent migration 1b8b740a6fa3) and work across SQLite, PostgreSQL, and MySQL.

Alternatively, commit per-flow or in batches to reduce the duration of a single transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/telemetry_writer/service.py` around lines
468 - 526, The current retention logic holds a single writer transaction while
issuing many per-flow and per-(flow,vertex) DELETEs (loops over tx_flows and
vb_flows using TransactionTable and VertexBuildTable), which causes contention;
replace those loops with single windowed DELETEs using a CTE with ROW_NUMBER()
OVER (PARTITION BY ...) to keep only the top N rows instead of per-flow deletes:
for transactions use PARTITION BY flow_id ORDER BY timestamp DESC and filter
row_number > max_transactions; for vertex builds use PARTITION BY flow_id, id
ORDER BY timestamp DESC, build_id DESC and filter row_number > max_per_vertex
(also remove the select distinct id loop and the vertex_ids iteration), and for
the global limit use a similar windowed CTE over build_id to enforce
max_vertex_builds; perform these DELETEs in one short transaction via
self._session_maker() (or alternatively flush/commit in batches) to reduce lock
time and round-trips.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/base/langflow/services/telemetry_writer/service.py`:
- Around line 415-427: The sweeper/flush race can remove newly-added flow_ids
from the shared sets because _flush adds to _dirty_tx_flows/_dirty_vb_flows
before await session.commit(), and _run_retention_pass snapshots then subtracts
them after commit; fix by changing _run_retention_pass to "capture-and-clear"
the live sets at the start (e.g., copy current _dirty_tx_flows/_dirty_vb_flows
into local variables and clear the original sets) and only restore any
unprocessed ids on failure, and ensure that after successful commit you do not
remove ids that were added after the snapshot; alternatively (recommended) also
move the additions in _flush (the calls that add flow_id when inserting into
TransactionTable/VertexBuildTable) to after await session.commit() so new dirty
marks occur only after a successful commit.

In `@src/backend/tests/stress/README.md`:
- Around line 28-30: The example uses an unset env variable
DB_URL="$LANGFLOW_DATABASE_URL"; replace that with a concrete Postgres DSN
matching the container started above (e.g. export
DB_URL="postgresql://user:password@localhost:5432/langflow_test") so the uv run
python src/backend/tests/stress/stress_telemetry_writes.py --concurrency 200
--seconds 15 command can run in a clean shell; update the README.md snippet to
show the explicit DSN instead of LANGFLOW_DATABASE_URL and keep the rest of the
example (the uv run invocation and flags) unchanged.

In `@src/backend/tests/unit/services/telemetry_writer/test_service.py`:
- Around line 244-250: The unbounded PID-probing loops using variable dead_pid
and function _pid_alive can hang CI; add a maximum probe count (e.g.,
MAX_PROBES) and increment a probe counter inside the loop that breaks and fails
the test with an explicit assertion or exception if the max is exceeded, then
proceed to set dead_dir = tmp_path / str(dead_pid); apply the same bounded-probe
pattern to the other loop referenced (the one around lines 276-277) so both
loops use _pid_alive with a capped number of attempts and a clear failure path.

---

Nitpick comments:
In `@src/backend/base/langflow/services/telemetry_writer/service.py`:
- Around line 359-376: The loop in _run_writer currently reads
telemetry_writer_batch_size and telemetry_writer_flush_interval_s only once at
the top, so runtime changes aren't picked up; move the retrieval of batch_size
and flush_interval into the while True loop (or read them from a cached
attribute that is refreshed each iteration) so they are re-read each iteration
from self.settings_service.settings (consistent with how _run_sweeper and
_enqueue re-check settings). Update _run_writer to obtain batch_size =
int(getattr(self.settings_service.settings, "telemetry_writer_batch_size", 200))
and flush_interval = float(getattr(self.settings_service.settings,
"telemetry_writer_flush_interval_s", 0.5)) inside the loop before using them.
- Around line 389-407: The escalation ERROR log only fires once because the code
checks if consecutive_failures == _FAILURE_ESCALATION_THRESHOLD; change this to
either trigger for every failure beyond the threshold (use consecutive_failures
>= _FAILURE_ESCALATION_THRESHOLD) or log at ERROR every Nth failure past the
threshold (e.g., if consecutive_failures >= _FAILURE_ESCALATION_THRESHOLD and
consecutive_failures % N == 0) and optionally promote the per-batch
logger.exception call to logger.error when consecutive_failures is past the
threshold; update the check near the exception handling block that references
consecutive_failures, _FAILURE_ESCALATION_THRESHOLD,
logger.exception/logger.error and failed_batches so operators continue to see
ERRORs while failures persist.
- Around line 173-192: The timeout handler in the shutdown path redundantly
calls self._writer_task.cancel() after asyncio.wait_for(self._writer_task,
timeout=drain_timeout) raises asyncio.TimeoutError (wait_for has already
cancelled the inner task); remove the explicit cancel() call in that except
asyncio.TimeoutError block so the code mirrors the CancelledError handler and
simply awaits self._writer_task inside the suppress(asyncio.CancelledError)
block while keeping the warning log (refer to self._writer_task, drain_timeout,
and the telemetry_writer_shutdown_drain_s setting in the message).
- Around line 468-526: The current retention logic holds a single writer
transaction while issuing many per-flow and per-(flow,vertex) DELETEs (loops
over tx_flows and vb_flows using TransactionTable and VertexBuildTable), which
causes contention; replace those loops with single windowed DELETEs using a CTE
with ROW_NUMBER() OVER (PARTITION BY ...) to keep only the top N rows instead of
per-flow deletes: for transactions use PARTITION BY flow_id ORDER BY timestamp
DESC and filter row_number > max_transactions; for vertex builds use PARTITION
BY flow_id, id ORDER BY timestamp DESC, build_id DESC and filter row_number >
max_per_vertex (also remove the select distinct id loop and the vertex_ids
iteration), and for the global limit use a similar windowed CTE over build_id to
enforce max_vertex_builds; perform these DELETEs in one short transaction via
self._session_maker() (or alternatively flush/commit in batches) to reduce lock
time and round-trips.

In `@src/backend/tests/unit/services/telemetry_writer/test_service.py`:
- Around line 112-122: Several test functions (notably
test_is_enabled_reads_settings and test_enqueue_when_not_running_returns_false
and other tests in the file ranges indicated) lack one-line docstrings; add a
concise single-line docstring at the top of each test function describing its
purpose (e.g., "Verify writer reads enabled setting" for
test_is_enabled_reads_settings, "Ensure enqueue returns False when writer not
running" for test_enqueue_when_not_running_returns_false) to comply with the
backend test standard and improve failure triage; apply the same pattern to the
other test functions in the indicated ranges (125-133, 144-193, 219-241,
266-295, 323-424).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f206269-2a31-4c75-847d-32ff1c40b645

📥 Commits

Reviewing files that changed from the base of the PR and between 3da78df and 9358bdc.

📒 Files selected for processing (17)
  • pyproject.toml
  • src/backend/base/langflow/main.py
  • src/backend/base/langflow/services/deps.py
  • src/backend/base/langflow/services/schema.py
  • src/backend/base/langflow/services/telemetry_writer/__init__.py
  • src/backend/base/langflow/services/telemetry_writer/factory.py
  • src/backend/base/langflow/services/telemetry_writer/service.py
  • src/backend/base/langflow/services/transaction/service.py
  • src/backend/base/langflow/services/utils.py
  • src/backend/tests/stress/README.md
  • src/backend/tests/stress/__init__.py
  • src/backend/tests/stress/stress_telemetry_writes.py
  • src/backend/tests/unit/services/telemetry_writer/__init__.py
  • src/backend/tests/unit/services/telemetry_writer/test_service.py
  • src/lfx/src/lfx/graph/utils.py
  • src/lfx/src/lfx/services/schema.py
  • src/lfx/src/lfx/services/settings/base.py

Comment on lines +415 to +427
if tx_batch:
await session.execute(TransactionTable.__table__.insert(), tx_batch)
for row in tx_batch:
flow_id = row.get("flow_id")
if flow_id is not None:
self._dirty_tx_flows.add(str(flow_id))
if vb_batch:
await session.execute(VertexBuildTable.__table__.insert(), vb_batch)
for row in vb_batch:
flow_id = row.get("flow_id")
if flow_id is not None:
self._dirty_vb_flows.add(str(flow_id))
await session.commit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Sweeper-vs-flush race can silently drop dirty flow_ids.

_run_retention_pass snapshots _dirty_*_flows at the start (Lines 463–466) and clears the snapshot from the live set via -= after commit (Lines 535–536). Meanwhile, _flush adds flow_ids to the same sets before its commit (Lines 420, 426). When a flush's dirty.add(...) interleaves between the snapshot and the post-commit -=, the freshly-flushed flow_id is removed from dirty even though its rows weren't accounted for by the just-finished retention pass. In single-threaded asyncio this window opens every time the sweeper yields on await session.commit() (Line 526) while a concurrent flush is in flight.

Effect: per-flow cap can overshoot by up to one flush's worth of rows for the affected flow, until that flow sees new activity (which re-marks it dirty). Bounded, self-healing — but a correctness wart worth removing.

A cleaner pattern is "capture-and-clear" at the start of the sweep, and restore-on-failure only:

♻️ Proposed dirty-set handover
-        tx_flow_snapshot = set(self._dirty_tx_flows)
-        vb_flow_snapshot = set(self._dirty_vb_flows)
+        # Hand off ownership of the current dirty set to this sweep. New
+        # flush activity during the sweep accumulates into a fresh set and
+        # is picked up on the next pass.
+        tx_flow_snapshot = set(self._dirty_tx_flows)
+        vb_flow_snapshot = set(self._dirty_vb_flows)
+        self._dirty_tx_flows -= tx_flow_snapshot
+        self._dirty_vb_flows -= vb_flow_snapshot
         tx_flows = [_as_uuid(f) for f in tx_flow_snapshot]
         vb_flows = [_as_uuid(f) for f in vb_flow_snapshot]
@@
                 await session.commit()
         except Exception:
-            # Sweep failed before commit — re-mark the snapshot as dirty so the
-            # next sweep retries these flows. Without this the per-flow caps
-            # could overshoot indefinitely until those flows see new writes.
+            # Sweep failed before commit — restore the handed-off snapshot
+            # so the next sweep retries these flows.
             self._dirty_tx_flows |= tx_flow_snapshot
             self._dirty_vb_flows |= vb_flow_snapshot
             raise
-        else:
-            self._dirty_tx_flows -= tx_flow_snapshot
-            self._dirty_vb_flows -= vb_flow_snapshot

Optionally, moving _dirty_*_flows.add(...) in _flush to run after await session.commit() succeeds also reduces dirty noise when commits fail (the rows are re-flushed anyway, which will re-mark them).

Also applies to: 463-466, 535-536

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/telemetry_writer/service.py` around lines
415 - 427, The sweeper/flush race can remove newly-added flow_ids from the
shared sets because _flush adds to _dirty_tx_flows/_dirty_vb_flows before await
session.commit(), and _run_retention_pass snapshots then subtracts them after
commit; fix by changing _run_retention_pass to "capture-and-clear" the live sets
at the start (e.g., copy current _dirty_tx_flows/_dirty_vb_flows into local
variables and clear the original sets) and only restore any unprocessed ids on
failure, and ensure that after successful commit you do not remove ids that were
added after the snapshot; alternatively (recommended) also move the additions in
_flush (the calls that add flow_id when inserting into
TransactionTable/VertexBuildTable) to after await session.commit() so new dirty
marks occur only after a successful commit.

Comment thread src/backend/tests/stress/README.md Outdated
Comment on lines +28 to +30
export DB_URL="$LANGFLOW_DATABASE_URL" # any standard postgres DSN works
uv run python src/backend/tests/stress/stress_telemetry_writes.py \
--concurrency 200 --seconds 15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a concrete Postgres DSN in the example.

Line 28 currently derives DB_URL from LANGFLOW_DATABASE_URL, which may be unset in a clean shell and makes this runbook step fail. Use an explicit DSN matching the container started above.

Suggested doc fix
-export DB_URL="$LANGFLOW_DATABASE_URL"  # any standard postgres DSN works
+export DB_URL="postgresql+psycopg://langflow:langflow@localhost:55432/langflow"
+# (or any standard postgres DSN)
 uv run python src/backend/tests/stress/stress_telemetry_writes.py \
     --concurrency 200 --seconds 15
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export DB_URL="$LANGFLOW_DATABASE_URL" # any standard postgres DSN works
uv run python src/backend/tests/stress/stress_telemetry_writes.py \
--concurrency 200 --seconds 15
export DB_URL="postgresql+psycopg://langflow:langflow@localhost:55432/langflow"
# (or any standard postgres DSN)
uv run python src/backend/tests/stress/stress_telemetry_writes.py \
--concurrency 200 --seconds 15
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/stress/README.md` around lines 28 - 30, The example uses an
unset env variable DB_URL="$LANGFLOW_DATABASE_URL"; replace that with a concrete
Postgres DSN matching the container started above (e.g. export
DB_URL="postgresql://user:password@localhost:5432/langflow_test") so the uv run
python src/backend/tests/stress/stress_telemetry_writes.py --concurrency 200
--seconds 15 command can run in a clean shell; update the README.md snippet to
show the explicit DSN instead of LANGFLOW_DATABASE_URL and keep the rest of the
example (the uv run invocation and flags) unchanged.

Comment on lines +244 to +250
while dead_pid > 0:
from langflow.services.telemetry_writer.service import _pid_alive

if not _pid_alive(dead_pid):
break
dead_pid += 1
dead_dir = tmp_path / str(dead_pid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Bound dead-PID probing loops to prevent hung CI runs.

Both PID search loops are unbounded; if _pid_alive behaves unexpectedly on a runner, these tests can stall indefinitely. Add a bounded probe with a clear failure path.

Proposed patch
+def _find_dead_pid(start: int = 99_999, max_checks: int = 50_000) -> int:
+    from langflow.services.telemetry_writer.service import _pid_alive
+
+    pid = start
+    for _ in range(max_checks):
+        if not _pid_alive(pid):
+            return pid
+        pid += 1
+    pytest.fail("Unable to find an unused PID for orphan outbox setup")
+
 def test_adopt_orphan_outboxes(tmp_path: Path) -> None:
     # Simulate a dead worker that left rows in a sibling outbox.
-    dead_pid = 99999
-    while dead_pid > 0:
-        from langflow.services.telemetry_writer.service import _pid_alive
-
-        if not _pid_alive(dead_pid):
-            break
-        dead_pid += 1
+    dead_pid = _find_dead_pid()
@@
 def test_adopt_orphan_outboxes_honors_max_queue(tmp_path: Path) -> None:
@@
-    from langflow.services.telemetry_writer.service import _pid_alive
-
-    dead_pid = 99999
-    while _pid_alive(dead_pid):
-        dead_pid += 1
+    dead_pid = _find_dead_pid()

Also applies to: 276-277

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/telemetry_writer/test_service.py` around
lines 244 - 250, The unbounded PID-probing loops using variable dead_pid and
function _pid_alive can hang CI; add a maximum probe count (e.g., MAX_PROBES)
and increment a probe counter inside the loop that breaks and fails the test
with an explicit assertion or exception if the max is exceeded, then proceed to
set dead_dir = tmp_path / str(dead_pid); apply the same bounded-probe pattern to
the other loop referenced (the one around lines 276-277) so both loops use
_pid_alive with a capped number of attempts and a clear failure path.

- Sweeper hands off dirty sets via capture-and-clear so concurrent
  flushes during a retention pass aren't wiped by the post-commit
  subtract; failure path restores the snapshot.
- Stress README uses a concrete Postgres DSN matching the docker
  example instead of an unset env var.
- Test PID-probe loops bounded via a shared helper with pytest.fail
  fallback.
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 14, 2026
@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.06700% with 119 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.31%. Comparing base (e12086f) to head (0f2f790).
⚠️ Report is 1 commits behind head on release-1.10.0.

Files with missing lines Patch % Lines
...base/langflow/services/telemetry_writer/service.py 80.61% 101 Missing ⚠️
src/lfx/src/lfx/graph/utils.py 11.76% 15 Missing ⚠️
src/backend/base/langflow/main.py 57.14% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##           release-1.10.0   #13126      +/-   ##
==================================================
+ Coverage           58.13%   58.31%   +0.17%     
==================================================
  Files                2286     2288       +2     
  Lines              218405   219061     +656     
  Branches            32850    32922      +72     
==================================================
+ Hits               126965   127735     +770     
+ Misses              89985    89871     -114     
  Partials             1455     1455              
Flag Coverage Δ
backend 65.15% <81.32%> (+0.19%) ⬆️
frontend 57.59% <ø> (+0.18%) ⬆️
lfx 54.20% <62.50%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/backend/base/langflow/services/deps.py 93.02% <100.00%> (+3.86%) ⬆️
src/backend/base/langflow/services/schema.py 100.00% <100.00%> (ø)
...base/langflow/services/telemetry_writer/factory.py 100.00% <100.00%> (ø)
...kend/base/langflow/services/transaction/service.py 100.00% <100.00%> (ø)
src/backend/base/langflow/services/utils.py 82.56% <100.00%> (+0.10%) ⬆️
src/lfx/src/lfx/services/schema.py 100.00% <100.00%> (ø)
src/lfx/src/lfx/services/settings/base.py 80.00% <100.00%> (+0.77%) ⬆️
src/backend/base/langflow/main.py 61.61% <57.14%> (+0.33%) ⬆️
src/lfx/src/lfx/graph/utils.py 24.63% <11.76%> (-1.16%) ⬇️
...base/langflow/services/telemetry_writer/service.py 80.61% <80.61%> (ø)

... and 60 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Frontend Unit Test Coverage Report

Coverage Summary

Lines Statements Branches Functions
Coverage: 42%
42.99% (57245/133153) 69.39% (7806/11248) 41.4% (1282/3096)

Unit Test Results

Tests Skipped Failures Errors Time
4924 0 💤 0 ❌ 0 🔥 12m 42s ⏱️

@github-actions github-actions Bot removed the performance Maintenance tasks and housekeeping label May 15, 2026
@github-actions

github-actions Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

✅ Test Coverage Advisor

No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉

Advisory check only — never blocks merge.

Today the writer bounds memory by row count only, so a worker logging fat
vertex_build artifacts can hold tens of MB per row and silently dwarf the
configured max_queue. Add a size_strategy switch ('count' | 'bytes' |
'either', default 'count' for parity) with two byte thresholds:

- batch_size_bytes (256KB) caps per-flush INSERT size when bytes apply
- max_queue_bytes (200MB) drops oldest by bytes when bytes apply

Parallel sizes deques mirror the payload buffers so accounting stays
consistent across drain, spill, restore, and the cancel/retry rollback.
Single rows above the byte budget are still emitted (no row is refused);
operators get dropped_*_bytes counters alongside dropped_*.
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 15, 2026
…d paths

Address gaps in the byte-aware strategy tests:
- pin exact dropped counts and remaining buffer state for the drop-by-bytes
  case (was 'greater than zero')
- compute exact drain count from encoded size (was a 1-4 range)
- round-trip bytes through spill+restore to verify size deque is rebuilt
- round-trip bytes through orphan adoption for the same reason
- exercise the sweeper loop end-to-end to confirm heartbeat + prune are
  wired in (previously only unit-tested in isolation)

Clarify in the settings docs that the byte caps measure encoded JSON size,
not Python in-memory footprint.
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 15, 2026
@erichare
erichare deleted the branch release-1.10.0 May 19, 2026 22:39
@erichare erichare closed this May 19, 2026
@erichare erichare reopened this May 19, 2026
# Conflicts:
#	.secrets.baseline
#	src/lfx/src/lfx/_assets/component_index.json
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels May 22, 2026
* fix(telemetry-writer): harden error handling and add missing test coverage

Critical:
- teardown() now re-raises CancelledError after awaiting the writer task so the
  asyncio cancellation chain propagates correctly on lifespan task kill
- suppress(OSError) on owner file write replaced with explicit error log so
  operators know when disk-spilled rows will not be recoverable on restart

High / important:
- Escalation threshold check changed from == to >= so the error log fires on
  every failure past the threshold, not just the 6th
- Dead `except OSError` on time.time() - time.monotonic() changed to
  `except Exception` since time functions cannot raise OSError
- Removed redundant `from uuid import UUID` inside _run_retention_pass (already
  imported at module top)
- Orphan directory cleanup: replaced blanket suppress(OSError) with per-child
  suppress so ENOTEMPTY on an individual rmdir doesn't abort the whole loop and
  leave the parent directory leaking silently; outer failure now logs at debug

Tests (3 new):
- test_retention_sweep_caps_vertex_builds_per_vertex: inserts 8 builds for a
  single vertex with max_per_vertex=3 so the per-vertex DELETE subquery
  actually executes (previous test used 8 distinct vertex IDs, bypassing it)
- test_either_strategy_trips_on_bytes_first: verifies bytes can be the first
  trigger under 'either' strategy (previous test only covered the count-first path)
- test_writer_retries_on_batch_failure: injects 2 flush failures then success;
  confirms failed_batches increments, rows are preserved in the buffer, and
  flushed_rows reflects the final successful write

* ci: add stress-tests job to nightly build pipeline

Wires the stress-tests workflow into nightly so telemetry write stress
tests run automatically. Also gates release-nightly-build and
slack-notification on stress-tests result so a stress regression blocks
the nightly release and surfaces in Slack.

* ci: run stress tests in nightly without blocking release

Stress tests are informational for now — a failure is visible in the
workflow run and Slack but does not gate the nightly release or build.

* fix(telemetry-writer): address PR review on cancelled teardown and nightly stress tests

- Add the stress-tests.yml reusable workflow (was untracked, so the
  nightly stress-tests job could never resolve)
- Notify Slack on stress-tests failure (add to slack-notification gate
  and FAILED_JOB detection as non-blocking)
- Run sweeper cancel, disk spill, and engine dispose in a finally so a
  cancelled teardown() still persists the in-memory buffer
- Add tests for the cancelled-teardown spill path and the >= escalation
  threshold; drop the misleading wait-loop in the retry test
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels Jun 2, 2026

@jordanrfrazier jordanrfrazier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm. ran some tests, operation looks good. Didn't run any load tests.

# Conflicts:
#	.github/workflows/nightly_build.yml
#	.secrets.baseline
#	src/lfx/src/lfx/services/schema.py
@github-actions github-actions Bot added performance Maintenance tasks and housekeeping and removed performance Maintenance tasks and housekeeping labels Jun 5, 2026
@ogabrielluiz
ogabrielluiz enabled auto-merge June 5, 2026 17:07
@ogabrielluiz
ogabrielluiz added this pull request to the merge queue Jun 5, 2026
Merged via the queue into release-1.10.0 with commit 0d9f911 Jun 5, 2026
118 of 119 checks passed
@ogabrielluiz
ogabrielluiz deleted the perf/telemetry-writer branch June 5, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix-index performance Maintenance tasks and housekeeping

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants