Type: infra investigation (qa-infra, Wave 3 — Infra stabilization & test coverage)
Status: diagnosis + recommendation. No infra change is made by this issue — the
milestone rule is explicit: act only after diagnosis/isolation rule out a product
regression. Acting (changing the runner / sharding) is a separate follow-up.
Last updated: 2026-07-18
The issue's premise is outdated, and a direct measurement disproves the CPU-contention theory. The daily does not run on a 2 vCPU / 7 GB VM — GitHub has upgraded the free public-repo standard
ubuntu-latestto 4 vCPU / 16 GB. A resource sampler run (§3, run [29658914382]) shows the VM is not resource-starved: load1 mean 2.80 / max 5.42 on 4 cores, memory peak 47 %. Crucially, the timeout failures do not correlate with CPU saturation — during the 107 failing test-results the load averaged 2.57/4 and only 16 of them were anywhere near saturation. So raw VM CPU/mem is not the root cause.
The real bottleneck is the single shared langflow backend: workers=2 sends two
concurrent streams of requests to one Python backend, which serializes on graph
execution / model calls. The browser then waits past the 20 s actionTimeout even though
the VM has spare cores — which is exactly why the failures are timeout-dominated yet
uncorrelated with VM load. A share of the 34 hard failures is also genuine nightly
product slowness/regressions, not infra at all.
Recommendation (revised after measurement + Option-0 investigation):
- Shard the
@stablesuite across N standard runners (#833) — primary fix. Each shard keeps the known-good single-workerlangflow+ SQLite config, so it removes the single-backend serialization without Redis/Postgres, and cuts wall-clock ~N×. Standard runners are free/unlimited on this public repo. Needs blob-merge + downstream-aggregation rework → own Wave impl issue (#833). - Reject raising
LANGFLOW_WORKERS— investigated: multi-worker langflow needs a Redis job queue (build path is per-process in-memory) and Postgres (SQLite locks under concurrency). That is more new services/failure modes than sharding, and it abandons the single-worker/SQLite config the suite was validated on. - Reject the "larger runner" option — the runner is already 4-core, memory is at 47 %, and failures don't track CPU saturation, so more cores/RAM won't help.
- Reject raising timeouts / dropping to
workers=1as the primary lever (masks the root cause / doubles wall-clock). - Keep triaging genuine product-finding failures separately (§2.3).
Source: .github/workflows/daily-stable.yml, playwright.config.ts.
| Dimension | Value | Source |
|---|---|---|
| Runner | ubuntu-latest — actually 4 vCPU / 16 GB (measured nproc=4, mem total 15988 MB; GitHub upgraded the free public-repo standard runner. The issue's "2 vCPU / 7 GB" premise is outdated.) |
daily-stable.yml:34 + §3 |
| Test container | mcr.microsoft.com/playwright:v1.58.2-noble |
daily-stable.yml:43 |
| Workers | 2 in CI (process.env.CI ? 2 : undefined) |
playwright.config.ts:18 |
| Per-test timeout | 5 min; actionTimeout 20 s |
playwright.config.ts:19,28 |
| Retries | 2 in CI | playwright.config.ts:17 |
| Co-tenant services | langflow (single instance), ollama (llama3.2:1b, CPU inference), go-httpbin |
daily-stable.yml:45-128 |
@stable tests |
353 (scripts/stable-tests.ts --count) |
live |
The core problem — a single shared langflow backend, NOT VM CPU. A run concurrently
loads 2 Playwright workers + 2 Chromium browsers, 1 shared langflow backend serving
both workers, ollama (CPU inference), and go-httpbin. The original theory was that
these oversubscribe the CPU. The §3 measurement refutes that — on 4 cores the VM runs
at ~70 % mean load with spare headroom, and the timeout failures do not line up with the
load peaks. The real serialization point is the single langflow backend: two workers
issue concurrent graph-execution / model-call requests to one Python process, which
handles them serially, so a worker's UI action can wait past the 20 s actionTimeout
while the CPU sits idle. That is the timeout-dominated-yet-uncorrelated pattern §2/§3 show.
13 scheduled runs, 2026-07-01 → 2026-07-17.
Clustered error_signature across all runs (failures[], N = normalized digits):
| Count | Signature | Contention-type? |
|---|---|---|
| 42 | TimeoutError: locator.click: Timeout Nms exceeded |
✅ |
| 18 | expect(locator).toBeVisible() failed |
✅ (slow render) |
| 15 | TimeoutError: page.waitForSelector: Timeout Nms exceeded |
✅ |
| 8 | TimeoutError: locator.hover: Timeout Nms exceeded |
✅ |
| 9 + 9 | toContain / toBe equality |
mixed |
| 3 | locator.waitFor timeout |
✅ |
| 2 | page.waitForResponse timeout |
✅ |
| … | (product-specific: MCP 4xx, provider probe, tool indicator) | ❌ real findings |
~86 of 142 hard-failure entries (≈60%) are timeout / slow-render — plus 38 of 175 flaky entries carry a timeout signature. A suite where the dominant failure mode is "the browser could not act within 20 s" is starved for CPU, not broadly broken.
date · duration · hard-failures(of which timeout) · flaky(of which timeout):
2026-07-01 31m F=2(to=1) FL=1(to=0)
2026-07-02 25m F=4(to=4) FL=1(to=0)
2026-07-03 21m F=1(to=0) FL=1(to=0) ← short/clean
2026-07-06 20m F=1(to=0) FL=1(to=0) ← short/clean
2026-07-07 38m F=5(to=1) FL=1(to=0)
2026-07-08 41m F=28(to=27) FL=1(to=0) ← heavy → 27 timeout fails
2026-07-09 25m F=4(to=4) FL=1(to=0)
2026-07-10 34m F=4(to=0) FL=1(to=0)
2026-07-13 58m F=12(to=1) FL=1(to=0)
2026-07-14 60m F=27(to=9) FL=27(to=12) ← heavy → 21 timeout total
2026-07-15 47m F=15(to=2) FL=29(to=14)
2026-07-16 52m F=6(to=0) FL=13(to=8)
2026-07-17 67m F=34(to=24) FL=10(to=4) ← longest → 28 timeout total
Duration grew 20 → 67 min as the passing count grew 214 → 373 (suite growth). The heaviest/longest runs (07-08, 07-14, 07-17) are exactly the timeout spikes; the short runs (07-03, 07-06) are clean. Load ↑ → timeouts ↑ is the contention signature. The clean short runs also prove the infra can go green — so a large share of the failures is contention/transient, not a standing product regression.
Not every timeout is contention: some entries are genuine product findings (MCP 4xx mismatches, provider-probe failures, "agent answered without invoking any tool"). Those must keep failing and be triaged on their own dedicated issues — they are not what an infra fix addresses. The recommendation targets the ~60% timeout/serialization bucket only.
A temporary resource sampler (loadavg + mem at ~1 Hz → CSV artifact) was added to
daily-stable.yml and a manual workflow_dispatch run executed against
langflowai/langflow-nightly:latest: run 29658914382 (2026-07-18, 55 min, 324
expected / 34 unexpected / 7 flaky / 46 skipped — a normal heavy day). 3312 samples.
| Metric | Value (4 cores / 16 GB) | Reading |
|---|---|---|
nproc |
4 | runner is 4-core, not 2 |
| load1 mean | 2.80 | ~70 % of 4 cores |
| load1 max | 5.42 | brief 135 % bursts |
| samples load1 > 4 (saturated) | 16 % | saturation is the exception |
| samples load1 > 6 | 0 % | never severely pegged |
| memory peak | 7.5 GB / 16 GB (47 %) | never a constraint |
Correlating the 107 failing test-results' startTime+duration windows against the
sampler:
| Metric during failing tests | Value | vs overall |
|---|---|---|
| load1 mean while failing | 2.57 | ≈ overall 2.80 — no elevation |
| failing-window samples > 4 cores | 12 % | ≈ overall 16 % |
| failures with any peak > 4 cores | 16 / 107 | most failed at normal load |
If CPU contention drove the timeouts, the failing windows would sit at saturation. They
don't — they sit at the same load as the passing ones. Conclusion: raw VM CPU/mem is
not the cause. The single shared langflow backend (serialization) + genuine nightly
product slowness explain the timeout-dominated failures, consistent with §1 and §2.3.
(The sampler step is temporary instrumentation — see PR #832; remove after the decision.)
The §3 data points at the shared backend, so the first idea was: bump the langflow service's
own worker count so it stops serializing both Playwright workers' requests. The knob exists
(LANGFLOW_WORKERS, default 1; on Unix langflow runs Gunicorn pre-fork). But
multi-worker langflow is not a one-line env bump — it requires new stateful services:
- Redis job queue is mandatory. The build job queue is in-memory per-process: "a
flow build started on worker A cannot be polled or streamed from worker B." Multi-worker
requires
LANGFLOW_JOB_QUEUE_TYPE=redis+LANGFLOW_REDIS_QUEUE_URL(a new Redis service). The E2E suite is playground/flow-build heavy — exactly the pollable build path that breaks across workers. - SQLite locks under concurrent workers. Docs recommend Postgres; we have already
hit a SQLite lock bug on concurrent writes (
LANGFLOW-BUG-bulk-flow-delete-sqlite-lock.md).
| ✅ Pro | Would relieve the serialization within a single instance. |
| ❌ Con | Not free / not one-line — needs a Redis service and (realistically) Postgres, plus the JOB_QUEUE_TYPE/REDIS_QUEUE_URL/WORKER_TIMEOUT config. That is more new services + failure modes than Option B, and it abandons the known-good single-worker+SQLite config the whole suite was validated against. Rejected as the cheap win. |
Was: bump runs-on to a bigger runner for more CPU.
| ❌ Reject | The runner is already 4-core / 16 GB, memory peaks at 47 %, and failures do not correlate with CPU saturation (§3.2). More cores/RAM would not move the timeout rate — and larger runners are paid even on this public repo. No reason to pursue. |
Matrix of N jobs (--shard=i/N), each on a standard ubuntu-latest with its own
langflow/ollama/go-httpbin services; merge the N blob reports at the end. (#833)
| ✅ Pro | Free — standard runners are unlimited on this public repo; wall-clock drops ~N×; each shard gets a dedicated single-worker langflow + SQLite — the known-good config, so it removes the serialization without needing Redis/Postgres (unlike Option 0); scales with suite growth by raising N. |
| ❌ Con | Real engineering: (1) merge N blob reports into one results.json before the downstream steps; (2) the auto-remove-@stable, history append, QA-Platform POST, and failure-issue steps all assume a single results.json → must run post-merge; (3) N× langflow boots (~90 s each) + N service stacks; (4) @database state-sharing tests must stay within one shard (shard by file with affinity, never split a dependent pair). → Own Wave implementation issue (#833). |
workers=1: ✅ removes the 2-workers-vs-1-backend contention, free, one line. ❌ roughly doubles wall-clock — 67 min → ~2 h, breaching the 90 min job timeout. Wrong direction on the current single-runner setup. Note —workersis not an independent lever; it is coupled to the shard decision: dropping toworkers=1on today's single runner is the wrong trade (doubles wall-clock). The clean design isworkers=1per shard under Option B — each shard gets one dedicated single-workerlangflow, so the backend serialization disappears and parallelism comes from the shard count, not the worker count. Do not tuneworkersin isolation.- Raise
actionTimeout/test timeout: ✅ trivial. ❌ masks the root cause, slows every test, and violates the repo rule never add waits/retries to go green. Reject as a primary lever. - Stagger heavy services (e.g. gate ollama/agent specs to a separate window): partial relief but fragile and doesn't address the shared-backend contention.
Direct measurement (§3) reframed the whole issue: the runner is already 4-core / 16 GB
and not resource-starved, and the timeout failures do not correlate with CPU load.
The bottleneck is the single shared langflow backend, not runner sizing. Raising the
backend's own worker count (Option 0) was investigated and is not the cheap lever it
looked like — multi-worker langflow needs Redis + Postgres and abandons the known-good
single-worker/SQLite config.
- Implement sharding (Option B, #833) as the primary fix. It is the only option that
removes the single-backend serialization while keeping each shard on the known-good
single-worker
langflow+ SQLite config — no Redis, no Postgres, no new stateful services. Free on this public repo; also cuts wall-clock ~N×. Carries the blob-merge + downstream-aggregation rework and the@databaseshard-affinity rule. - Reject Option 0 (raise
LANGFLOW_WORKERS) — it mandates a Redis job queue (the build path is per-process in-memory) + Postgres (SQLite locks under concurrency), i.e. more new services/failure modes than sharding. - Reject the larger runner (Option A) — §3 shows more CPU/RAM won't help, and it is paid.
- Do not raise timeouts or drop to
workers=1as the primary lever. - Keep triaging the genuine-product-finding failures (§2.3, e.g. the nightly MCP 4xx / provider-probe / tool-indicator failures) on their own issues — out of scope for runner sizing.
This issue delivers the diagnosis + recommendation only. The only workflow change is the temporary §3 sampler (PR #832), to be removed once the fix path is chosen.