Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
88560cb
feat: native v2 workflows endpoint with pluggable stream protocols
ogabrielluiz Jun 1, 2026
95f9102
feat(api): add output_text and session_id to v2 workflow response
ogabrielluiz Jun 2, 2026
461e593
test(api/v2): cover output_text and session_id on the v2 workflow res…
ogabrielluiz Jun 3, 2026
37d8a78
feat(api/v2): structured output with resolution reason on v2 response
ogabrielluiz Jun 3, 2026
f3c06ef
feat(api/v2): add request-side output selection (output_ids)
ogabrielluiz Jun 3, 2026
a1b9cb7
feat(api/v2): emit per-output events on the langflow stream
ogabrielluiz Jun 4, 2026
d696cae
feat(api/v2): durable background execution service (store + default b…
ogabrielluiz Jun 4, 2026
23a4884
test(background-execution): rename hard_proof marker to real_services
ogabrielluiz Jun 6, 2026
5652443
feat(api/v2): redis-scaled background execution backend + langflow wo…
ogabrielluiz Jun 4, 2026
79cbc59
test(background): fix locust quitting handler signature so job accoun…
ogabrielluiz Jun 5, 2026
2f961f5
test(background-execution): rename hard_proof marker to real_services…
ogabrielluiz Jun 6, 2026
3a7b8e7
Merge remote-tracking branch 'origin/release-1.10.0' into feat/v2-wor…
ogabrielluiz Jun 9, 2026
8cba565
fix(api/v2): enforce no-code-execution gate on public workflow endpoint
ogabrielluiz Jun 9, 2026
2e214d7
fix(api/v2): reconstruct background workflow status from job-keyed ve…
ogabrielluiz Jun 9, 2026
53660b3
Merge release-1.11.0 into feat/v2-workflows-agui
ogabrielluiz Jun 10, 2026
80b105a
Merge release-1.11.0 (via feat/v2-workflows-agui) into feat/v2-workfl…
ogabrielluiz Jun 10, 2026
7529370
Merge release-1.11.0 (via feat/v2-workflows-bg-default) into feat/v2-…
ogabrielluiz Jun 10, 2026
0c88299
fix(api/v2): merge workflow AG-UI cancellation hardening LE-1389
ogabrielluiz Jun 10, 2026
c84e965
fix(api/v2): signal cross-worker workflow stops LE-1389
ogabrielluiz Jun 10, 2026
54fc550
fix(api/v2): report unconfirmed workflow stops LE-1389
ogabrielluiz Jun 10, 2026
0aab6f8
Merge origin/feat/v2-workflows-agui into codex/v2-workflows-agui-merge
ogabrielluiz Jun 10, 2026
60469a6
fix(api/v2): keep background workflows out of polling watchdog LE-1389
ogabrielluiz Jun 10, 2026
6f5de50
fix(api/v2): buffer parallel messages in the AG-UI translator instead…
ogabrielluiz Jun 10, 2026
d96157e
Merge remote-tracking branch 'origin/feat/v2-workflows-agui' into HEAD
ogabrielluiz Jun 10, 2026
7417646
Merge remote-tracking branch 'origin/feat/v2-workflows-bg-default' in…
ogabrielluiz Jun 10, 2026
c03466e
fix(api/v2): gate AG-UI message finalization on non-partial state and…
ogabrielluiz Jun 10, 2026
f7c4024
Merge remote-tracking branch 'origin/feat/v2-workflows-agui' into HEAD
ogabrielluiz Jun 10, 2026
5e519e3
Merge remote-tracking branch 'origin/feat/v2-workflows-bg-default' in…
ogabrielluiz Jun 10, 2026
38dc899
Fix AG-UI workflow lifecycle edges
ogabrielluiz Jun 11, 2026
93d59c8
Merge branch 'feat/v2-workflows-agui' of https://github.com/langflow-…
ogabrielluiz Jun 11, 2026
906ab36
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 11, 2026
1c036d1
fix(frontend): enable downlevelIteration for jest Set/Map iteration
ogabrielluiz Jun 15, 2026
41d7e74
fix(api/v2): surface inactivated branch vertices over AG-UI
ogabrielluiz Jun 15, 2026
177e0a6
fix(api/v2): dedupe repeated inactive node deltas in AG-UI stream
ogabrielluiz Jun 15, 2026
f97bc7e
Merge feat/v2-workflows-agui into feat/v2-workflows-bg-default
ogabrielluiz Jun 15, 2026
610beec
Merge feat/v2-workflows-bg-default into feat/v2-workflows-bg-scaled
ogabrielluiz Jun 15, 2026
af680d4
fix(api/v2): no duplicate WORKFLOW job row on durable background runs
ogabrielluiz Jun 15, 2026
f134858
Merge feat/v2-workflows-bg-default into feat/v2-workflows-bg-scaled
ogabrielluiz Jun 15, 2026
ce42e8c
test(api/v2): update stale workflow-stop tests for the durable design
ogabrielluiz Jun 15, 2026
b996f12
Merge feat/v2-workflows-bg-default into feat/v2-workflows-bg-scaled
ogabrielluiz Jun 15, 2026
ee9bb50
fix(api/v2): move FrameSourceFactory alias under TYPE_CHECKING
ogabrielluiz Jun 15, 2026
0994dfb
Merge feat/v2-workflows-bg-default into feat/v2-workflows-bg-scaled
ogabrielluiz Jun 15, 2026
5ef7f5e
test(lfx): register background-execution Settings fields in the field…
ogabrielluiz Jun 15, 2026
ead732c
Merge feat/v2-workflows-bg-default into feat/v2-workflows-bg-scaled (…
ogabrielluiz Jun 15, 2026
548a4bf
fix(api/v2): restore "end" side-channel event in AG-UI workflow stream
ogabrielluiz Jun 16, 2026
1c920e0
Merge branch 'feat/v2-workflows-bg-default' into cascade-bg-scaled
ogabrielluiz Jun 16, 2026
518de30
fix(api/v2): apply request tweaks on the streaming and background paths
ogabrielluiz Jun 16, 2026
5d7463f
Merge branch 'feat/v2-workflows-bg-default' into cascade-bg-scaled
ogabrielluiz Jun 16, 2026
a092f9d
fix(api/v2): return background run output from completed status
ogabrielluiz Jun 16, 2026
1127a16
Merge branch 'feat/v2-workflows-bg-default' into cascade-bg-scaled
ogabrielluiz Jun 16, 2026
8a55e01
fix(api/v2): address review findings on the v2 workflows endpoint
ogabrielluiz Jun 22, 2026
e933ded
test(lfx): register public_flow_rate_limit_per_minute in settings com…
ogabrielluiz Jun 22, 2026
a6a1e35
refactor(v2 workflows): split workflow.py and address review blockers
ogabrielluiz Jun 22, 2026
2cda6c4
refactor(lfx): extract v2 workflow contract layer into lfx.workflow
ogabrielluiz Jun 23, 2026
9491f0a
Merge feat/lfx-v2-shared-layer into feat/v2-workflows-bg-default
ogabrielluiz Jun 23, 2026
174b45a
Merge feat/v2-workflows-bg-default into feat/v2-workflows-bg-scaled
ogabrielluiz Jun 23, 2026
2ae9bf5
fix(background-execution): prevent worker deadlock on stop() under Py…
ogabrielluiz Jun 29, 2026
e6f1d6b
merge: sync release-1.11.0 into feat/v2-workflows-bg-default
ogabrielluiz Jul 8, 2026
ccc8ff9
Merge branch 'feat/v2-workflows-bg-default' into feat/v2-workflows-bg…
ogabrielluiz Jul 8, 2026
a803082
refactor(api): scaled background backend rides the database, not redis
ogabrielluiz Jul 23, 2026
ec71d3c
Merge remote-tracking branch 'origin/release-1.11.0' into HEAD
ogabrielluiz Jul 23, 2026
e66934c
refactor(api): review round 2 for the DB-backed scaled backend
ogabrielluiz Jul 23, 2026
0188890
Merge remote-tracking branch 'origin/release-1.12.0' into HEAD
ogabrielluiz Aug 31, 2026
ae5d5e3
chore: retrigger CI after retargeting to release-1.12.0
ogabrielluiz Aug 31, 2026
c1f821b
fix(api): write the worker_lost error blob atomically with the FAILED…
ogabrielluiz Aug 31, 2026
907aaf3
fix: hand resumed jobs back to the queue in scaled mode
ogabrielluiz Sep 2, 2026
612804b
test: pin the fail_in_progress_job error-with-flip contract
ogabrielluiz Sep 2, 2026
6e648f2
fix(test): file-backed worker output so a chatty boot can't deadlock …
ogabrielluiz Sep 2, 2026
c6b0da6
Merge remote-tracking branch 'origin/release-1.12.0' into HEAD
ogabrielluiz Sep 2, 2026
0f89100
fix(migration): re-parent the job claim-scan index migration onto the…
ogabrielluiz Sep 2, 2026
ab90bdd
Merge remote-tracking branch 'origin/release-1.13.0' into HEAD
ogabrielluiz Sep 2, 2026
ba8c50f
fix(migration): re-parent the job claim-scan index migration onto the…
ogabrielluiz Sep 2, 2026
2bc76b9
fix(scaled): cancel suspended jobs directly instead of writing an unc…
ogabrielluiz Sep 10, 2026
bf02733
refactor(scaled): review hardening for database pressure and the back…
ogabrielluiz Sep 10, 2026
454619a
Merge remote-tracking branch 'origin/release-1.13.0' into HEAD
ogabrielluiz Sep 10, 2026
85d63d2
fix(migration): re-parent the claim-scan index onto 1.13's merge head…
ogabrielluiz Sep 10, 2026
bee10cf
ci: give the real-service Postgres job timeout headroom
ogabrielluiz Sep 10, 2026
32c4248
docs: separate the v1 build queue from background runs, document the …
ogabrielluiz Sep 18, 2026
890e6ad
Merge remote-tracking branch 'origin/release-1.13.0' into drift-fix
ogabrielluiz Sep 23, 2026
a437ef9
fix(migration): re-parent the claim-scan index onto the 1.13 head
ogabrielluiz Sep 23, 2026
839c8ce
fix(scaled): carry expose_graph_state into the worker's stream adapter
ogabrielluiz Sep 23, 2026
f1a54b0
Merge remote-tracking branch 'origin/release-1.13.0' into drift2
ogabrielluiz Sep 25, 2026
b82276d
Merge remote-tracking branch 'origin/release-1.13.0' into drift3
ogabrielluiz Sep 28, 2026
5709470
fix(migration): re-parent the claim-scan index onto the new 1.13 head
ogabrielluiz Sep 28, 2026
c5b52ba
Merge remote-tracking branch 'origin/release-1.13.0' into drift3
ogabrielluiz Sep 28, 2026
75b6538
docs: note job retention in the background settings reference
ogabrielluiz Sep 28, 2026
f43c75c
Merge remote-tracking branch 'origin/release-1.13.0' into drift5
ogabrielluiz Sep 29, 2026
11b91ba
fix(migration): re-parent the claim-scan index onto the new 1.13 head
ogabrielluiz Sep 29, 2026
d953cfe
Merge remote-tracking branch 'origin/release-1.13.0' into drift5
ogabrielluiz Oct 13, 2026
5721e14
fix(docs): resolve the retention reference by file path
ogabrielluiz Oct 13, 2026
cfde3bf
Merge remote-tracking branch 'origin/release-1.13.0' into drift5
ogabrielluiz Oct 15, 2026
38a3ac9
fix(jobs): guard the terminal success write
ogabrielluiz Oct 22, 2026
bbc766e
Merge remote-tracking branch 'origin/release-1.13.0' into drift5
ogabrielluiz Oct 22, 2026
9f74917
Merge remote-tracking branch 'origin/release-1.14.0' into drift5
ogabrielluiz Oct 22, 2026
ffa5f87
fix(migration): re-parent the claim-scan index onto the 1.14 head
ogabrielluiz Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions .github/workflows/migration-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:
uv run pytest src/backend/tests/unit/services/database/test_sqlite_to_postgres.py -x -v

background-real-service:
name: Background Execution Real-Service Tests (real Postgres + Redis)
name: Background Execution Real-Service Tests (real Postgres)
runs-on: ubuntu-latest

services:
Expand All @@ -124,15 +124,6 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5

steps:
- name: Checkout code
Expand All @@ -157,9 +148,12 @@ jobs:
- name: Run real-service tests
env:
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow" # pragma: allowlist secret
LANGFLOW_TEST_REDIS_URL: "redis://localhost:6379/0"
# Headroom over the suite's default 90s/120s watchdogs: these tests run
# real sweeps and restarts against the service Postgres, and contended
# shared runners have twice pushed a locally-seconds test past 90s.
LANGFLOW_TEST_HARD_TIMEOUT: "200"
run: |
uv run pytest src/backend/tests/unit/background_execution -m real_services -x -v
uv run pytest src/backend/tests/unit/background_execution -m real_services -x -v --timeout=180

validate-migration:
name: Migration Pattern Validation
Expand Down
72 changes: 72 additions & 0 deletions docs/docs/Deployment/deployment-multi-worker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ A multi-worker deployment runs more than one worker process on the same host. Co
A Redis-backed job queue stores build events in [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/), so any worker can pick up and serve any job's events.
To configure a multi-worker Langflow process, follow the steps to enable the Redis job queue.

:::note
The Redis job queue covers the v1 build endpoints (`/api/v1/build/*`). It has no effect on background runs of the v2 workflows API, which are configured separately with `LANGFLOW_BACKGROUND_BACKEND`. See [Scale background runs](#scale-background-runs).
:::

After the Redis job queue is configured, you can optionally follow [recommended Gunicorn settings](#recommended-gunicorn-settings) to reduce memory use and keep workers healthy. This tuning applies to Linux production hosts only. On Windows and macOS, `langflow run` uses a single Uvicorn process.

## Prerequisites
Expand Down Expand Up @@ -281,6 +285,59 @@ To verify that cancellation works across workers, trigger a build with the API t

For more information, see [Monitor the job queue](#monitor-the-job-queue).

## Scale background runs {#scale-background-runs}

Background runs are runs submitted to `POST /api/v2/workflows` with `"mode": "background"`. The request returns a job ID right away, and you poll, stream, stop, or resume the run with that ID later. The visual editor also uses background mode for runs that pause for human input.

Background runs are configured with `LANGFLOW_BACKGROUND_BACKEND`. They do not depend on `LANGFLOW_JOB_QUEUE_TYPE`.

### Default backend

With the default backend (`LANGFLOW_BACKGROUND_BACKEND=default`), a background run executes inside the API worker that accepted it, up to `LANGFLOW_BACKGROUND_MAX_CONCURRENCY` runs per worker.

The run's status, result, and events are stored in the database, so any worker can serve them. A worker that is not running the job reads new events from the database every 0.5 seconds. This works with multiple workers without extra configuration.

### Scaled backend

With the scaled backend (`LANGFLOW_BACKGROUND_BACKEND=scaled`), API workers only queue background runs, and separate `langflow worker` processes execute them. The queue is the Langflow database itself, so no additional infrastructure is required.

Use the scaled backend when background runs need to scale independently of the API, or when they should not compete with API requests for CPU and memory.

Each `langflow worker` process runs one background job at a time. To add capacity, run more worker processes.

The scaled backend requires the following:

- Every API process and every worker uses the same database. Use PostgreSQL when processes run on more than one host. SQLite only works when every process runs on the same host.
- Every process uses the same `LANGFLOW_SECRET_KEY`, because workers decrypt stored credentials and request overrides.
- Workers run the same Langflow version, with the same components and bundles installed, as the API. Workers build and run the flows themselves.
- At least one worker is running. Without a worker, background runs stay `queued`. The API logs a reminder at startup when the scaled backend is enabled.

:::tip
Set `LANGFLOW_BACKGROUND_RETENTION_DAYS` on long-lived deployments. With the scaled backend the job table is the work queue, so every finished run stays under the claim scan until something deletes it. Retention is off by default.
:::

To enable the scaled backend, set the following environment variables on the API and on every worker:

```text
LANGFLOW_DATABASE_URL=postgresql://langflow:langflow@your-postgres-host:5432/langflow
LANGFLOW_SECRET_KEY=your-secret-key
LANGFLOW_BACKGROUND_BACKEND=scaled
```

Then start one or more workers:

```bash
langflow worker
```

`langflow worker` accepts `--env-file` to load the variables from a file and `--log-level` to set the logging level.

The scaled backend changes the following behaviors:

- Event streams for background runs carry durable milestones, such as build progress, messages, and the final result. They do not carry token-by-token output.
- Stopping a run reaches its worker at the next component boundary.
- If a worker crashes mid-run, its job is reconciled after the lease expires (`LANGFLOW_BACKGROUND_LEASE_TTL_S`, 45 seconds by default), so a run is never left stuck as in progress.

## Troubleshoot

See [Troubleshoot multi-worker deployments](./troubleshoot#multi-worker-deployments).
Expand Down Expand Up @@ -314,6 +371,21 @@ LANGFLOW_RATE_LIMIT_STORAGE_URI=redis://your-redis-host:6379/2
For more information, see [Login rate limiting](/api-keys-and-authentication#login-rate-limiting).
:::

### Background run settings

These settings control background runs of the v2 workflows API. For details, see [Scale background runs](#scale-background-runs).

| Variable | Default | Description |
|---|---|---|
| `LANGFLOW_BACKGROUND_BACKEND` | `default` | `default` runs background jobs inside API workers. `scaled` queues them in the database for `langflow worker` processes to run. |
| `LANGFLOW_BACKGROUND_MAX_CONCURRENCY` | `5` | With the default backend, the number of background runs each API worker executes at once. |
| `LANGFLOW_BACKGROUND_POLL_INTERVAL_S` | `0.5` | With the scaled backend, how often in seconds an event stream checks the database for new events. |
| `LANGFLOW_BACKGROUND_LEASE_TTL_S` | `45` | How long in seconds a running job can go without a heartbeat before it is considered lost. Keep it well above `LANGFLOW_BACKGROUND_HEARTBEAT_INTERVAL_S`. |
| `LANGFLOW_BACKGROUND_HEARTBEAT_INTERVAL_S` | `15` | How often in seconds a running job refreshes its heartbeat. |
| `LANGFLOW_BACKGROUND_WATCHDOG_INTERVAL_S` | `15` | With the scaled backend, how often in seconds each worker checks for jobs whose worker has died. |
| `LANGFLOW_BACKGROUND_CLAIM_CANDIDATES` | `5` | With the scaled backend, how many of the oldest queued jobs a worker considers on each claim attempt. If workers sit idle while jobs are queued, raise it to roughly your worker count. |
| `LANGFLOW_BACKGROUND_RETENTION_DAYS` | `0` | Days to keep finished runs, with their events, signals, and checkpoints, before deleting them. `0` keeps every row forever. This covers every finished run in the job store, not only background runs. Live runs are never deleted at any age. See [`LANGFLOW_BACKGROUND_RETENTION_DAYS`](../Develop/environment-variables.mdx). |

## Monitor the job queue

The `GET /monitor/job_queue` endpoint returns a metrics snapshot for the running worker. It requires superuser authentication and returns HTTP 403 otherwise.
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/Develop/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,8 @@ The following environment variables set base Langflow server configuration, such
| `LANGFLOW_WORKERS` | Integer | `1` | Number of worker processes. See [Deploy Langflow with multiple workers](./deployment-multi-worker#recommended-gunicorn-settings). |
| `LANGFLOW_WORKER_TIMEOUT` | Integer | `300` | Worker timeout in seconds. See [Deploy Langflow with multiple workers](./deployment-multi-worker#recommended-gunicorn-settings). |
| `LANGFLOW_GUNICORN_PRELOAD` | Boolean | `False` | Enables Gunicorn `preload_app`. Non-Windows only. See [Deploy Langflow with multiple workers](./deployment-multi-worker#recommended-gunicorn-settings). |
| `LANGFLOW_JOB_QUEUE_TYPE` | String | `asyncio` | Job queue backend. Use `redis` for multi-worker deployments. See [Deploy Langflow with multiple workers](./deployment-multi-worker#configuration-reference). |
| `LANGFLOW_JOB_QUEUE_TYPE` | String | `asyncio` | Job queue backend for the v1 build endpoints (`/api/v1/build/*`). Use `redis` for multi-worker deployments. Does not affect background runs of the v2 workflows API; see `LANGFLOW_BACKGROUND_BACKEND`. See [Deploy Langflow with multiple workers](./deployment-multi-worker#configuration-reference). |
| `LANGFLOW_BACKGROUND_BACKEND` | String | `default` | Backend for background runs of the v2 workflows API. `default` runs them inside API workers. `scaled` queues them in the database for separate `langflow worker` processes to run. See [Scale background runs](./deployment-multi-worker#scale-background-runs). |
| `LANGFLOW_DANGEROUSLY_ALLOW_MULTI_WORKER_WITHOUT_SHARED_QUEUE` | Boolean | `False` | Allow startup with `LANGFLOW_WORKERS` greater than 1 while `LANGFLOW_JOB_QUEUE_TYPE` is `asyncio`. **Unsupported with the Langflow UI and with MCP over SSE.** See [Run multiple workers without Redis](./deployment-multi-worker#run-multiple-workers-without-redis). |
| `LANGFLOW_SSL_CERT_FILE` | String | Not set | Path to the SSL certificate file for enabling HTTPS on the Langflow web server. This is separate from [database SSL connections](/configuration-custom-database#connect-langflow-to-a-local-postgresql-database). |
| `LANGFLOW_SSL_KEY_FILE` | String | Not set | Path to the SSL key file for enabling HTTPS on the Langflow web server. This is separate from [database SSL connections](/configuration-custom-database#connect-langflow-to-a-local-postgresql-database). |
Expand Down
12 changes: 12 additions & 0 deletions docs/docs/Support/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,18 @@ If the deployment is headless and never uses the visual editor or MCP over SSE,

The polling watchdog reclaims builds whose client heartbeat has gone stale. The default threshold is 90 seconds (`LANGFLOW_REDIS_QUEUE_POLLING_STALE_THRESHOLD_S=90`). If legitimate long-running builds are being cancelled, increase this value or set it to `0` to disable the watchdog entirely.

### Background runs stay queued

With `LANGFLOW_BACKGROUND_BACKEND=scaled`, the API only queues background runs, and `langflow worker` processes execute them. If no worker is running against the same database, runs stay `queued` indefinitely.

Start at least one `langflow worker` with the same `LANGFLOW_DATABASE_URL` and `LANGFLOW_SECRET_KEY` as the API. For more information, see [Scale background runs](./deployment-multi-worker#scale-background-runs).

### Background runs fail with `worker_lost`

The worker running the job stopped sending heartbeats for longer than `LANGFLOW_BACKGROUND_LEASE_TTL_S` (45 seconds by default), so the run was marked failed. This usually means the worker process crashed or was killed, for example by the out-of-memory killer.

Check the worker logs around the time of the failure. If workers are healthy but under heavy load, increase `LANGFLOW_BACKGROUND_LEASE_TTL_S`, keeping it well above `LANGFLOW_BACKGROUND_HEARTBEAT_INTERVAL_S`.

## Voice input {#voice-mode}

If the **Voice input** button is disabled, your browser doesn't support the required speech-recognition API.
Expand Down
62 changes: 62 additions & 0 deletions src/backend/base/langflow/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,68 @@ def print_banner(host: str, port: int, protocol: str) -> None:
logger.info(f"Open Langflow: {protocol}://{access_host}:{port}")


@app.command()
def worker(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ogabrielluiz Things I have found and also sharing some opinions; please let me know what you think about them:

Review:

Overall, this is a good simplification. Making the durable job table the queue removes the second system of record, and the conditional-UPDATE (compare-and-swap) primitive is used consistently across claim / fail / retry/resume. The concurrency reasoning holds up — I traced the claim race and two workers cannot both run a job, even though the row stays QUEUED during the claim window.

My main reservation is database pressure, not correctness. Details below, plus two bugs in the suspended-job path.


Bugs

1. In scaled mode, stopping a SUSPENDED job does nothing

supersede_suspended_runs and stop_job both short-circuit to backend.stop() when scaled:

# service.py — supersede_suspended_runs
for stale_job_id in stale_job_ids:
    if self._scaled:
        await self._backend.stop(str(stale_job_id))
        cancelled.append(stale_job_id)
    elif await self._cancel_suspended(stale_job_id, job_service):
        cancelled.append(stale_job_id)

DBBackgroundQueue.stop() only writes a STOP signal:

async def stop(self, job_id: str) -> None:
    await self._job_service.write_signal(_coerce_uuid(job_id), SignalType.STOP)

But a STOP signal is only ever read by a live runner (runner._stop_requested, polled at durable frame boundaries). A SUSPENDED job has already exited _drive and has no worker attached. I grepped every STOP consumer — the only two are runner._stop_requested and _cancel_suspended, and the scaled branch skips the latter. Nothing reconciles a SUSPENDED row against a pending STOP.

Consequences in scaled mode:

  • The job stays SUSPENDED forever (background_input_deadline_s is None by default, so no sweep reaps it).
  • The HITL card is never superseded, so the stale pause keeps cluttering the badge / cards / trace bar — the exact thing supersede_suspended_runs exists to prevent.
  • The checkpoint is never deleted (storage leak per superseded pause).
  • The STOP signal sits unconsumed. If that job is later resumed, _drive hits its first durable frame, _stop_requested finds the stale STOP, and the resumed run immediately self-cancels. This is the failure mode consume_signals' own docstring warns about.

Same shape in stop_job — the scaled branch returns before the SUSPENDED check, so a user stop returns success on a job that never leaves SUSPENDED.

Suggested fix — branch on status, not on backend. A suspended job has no live worker in any mode, so _cancel_suspended is correct everywhere:

# supersede_suspended_runs
for stale_job_id in stale_job_ids:
    # A SUSPENDED job has no running worker in any mode — cancel it directly.
    if await self._cancel_suspended(stale_job_id, job_service):
        cancelled.append(stale_job_id)

# stop_job
job = await self._validate(job_id, user)
job_service = get_job_service()
if job.status == JobStatus.SUSPENDED and await self._cancel_suspended(job_id, job_service):
    return
if self._scaled:
    await self._backend.stop(str(job_id))   # genuinely in-flight run
    return

2. supersede_suspended_runs reports success unconditionally when scaled

In the scaled branch cancelled.append(stale_job_id) runs regardless of outcome, while the default branch only appends when _cancel_suspended actually won the CAS. Callers are told cleanup happened when it didn't. Fixed by the change above.


Main concern: database pressure

This design trades broker infrastructure for steady DB load. That's a reasonable trade, but a few things should land before this is run at scale.

3. No retention on job or job_events (please treat as a follow-up blocker)

The index migration comment says it outright:

"The job table has no retention, so without this composite the hottest query degrades into an ever-growing scan+sort over terminal rows."

The index helps, but nothing purges terminal rows and job_events grows one row per durable milestone forever. Over months the claim poll sorts out of an ever-growing table and the events table dominates storage. A scaled backend needs a companion reaper (age-based delete of terminal jobs + their events, or partitioning). Happy for it to be a separate PR, but it should be tracked before anyone enables scaled in production.

4. Watchdog runs in every worker with no jitter — periodic DB spikes

_watchdog_loop runs in every worker process on a fixed 15s interval, and each pass is a full IN_PROGRESS scan plus a get_job_by_job_id per row:

for job_id in await self._job_service.in_progress_workflow_job_ids():
    job = await self._job_service.get_job_by_job_id(job_id)

With 20 workers the same orphan set is read 20x. Correctness is fine (the conditional UPDATE single-flights the actual fix), but workers started together drift into lockstep and you get a synchronised burst every 15s rather than smooth load.

Two cheap improvements:

# worker.py — _watchdog_loop
# Random initial offset so a fleet started together doesn't scan in lockstep.
with contextlib.suppress(asyncio.TimeoutError):
    await asyncio.wait_for(stop_event.wait(), timeout=random.uniform(0, interval_s))

while not stop_event.is_set():
    with contextlib.suppress(Exception):
        await backend.requeue_lost(lease_ttl_s=lease_ttl_s)
    # +/-25% jitter so ticks stay spread out instead of re-converging.
    delay = interval_s * random.uniform(0.75, 1.25)
    try:
        await asyncio.wait_for(stop_event.wait(), timeout=delay)
    except asyncio.TimeoutError:
        continue

Longer term, consider whether the lease watchdog needs to run in every worker at all, or whether one holder (advisory lock / leader election) is enough — 20 workers duplicating the same scan is pure read amplification.

5. candidates is hardcoded at 5

async def claim_next_queued_lease(self, *, owner, lease_ttl_s, candidates: int = 5):

DBBackgroundQueue.claim() never passes candidates, so it's always 5. Fetching a small batch is a nice way to let workers spread across distinct jobs instead of all fighting over the head of the queue — but past ~5 workers the extras collide again, lose the CAS, and back off a full idle_block_ms. Either make it settings-driven and scaled to the expected fleet, or land the FOR UPDATE SKIP LOCKED path the ponytail comment already proposes.

6. Write amplification per milestone (noting, not asking for a change)

Each durable frame costs three round-trips: the stop-check SELECT, SELECT max(seq), and the INSERT. Fine for typical flows; worth knowing it scales with milestones/sec across the fleet, and that PG NOTIFY would not reduce it. On SQLite this is the part that will contend first — append_event already has a "database is locked" retry loop, which tells the story. Given scaled + SQLite is single-host only, consider a startup warning rather than only a docstring note.

7. On PG LISTEN/NOTIFY

The # ponytail: markers already point at this and I agree with the direction — it removes idle claim polling and the ~4 queries/sec per connected event-tail client. Two things worth deciding up front when it happens:

  • It's Postgres-only, so the poll loop stays as the SQLite fallback. Dual paths, doubled test surface.
  • LISTEN holds a connection open. It must be one listener per process fanning out to in-process subscribers, not one per SSE client, or you swap query load for connection exhaustion.

Not asking for it here. My suggested order is retention → watchdog jitter → NOTIFY.


Making room for future backends (NATS JetStream and friends)

We'd like to add a JetStream backend later for durability plus higher throughput. The seam is nearly right, but two things would make that genuinely additive rather than a refactor:

a) Formalise the backend contract. It's currently duck-typed with no base class. An explicit Protocol documents what a new backend must satisfy and makes the surface reviewable:

class BackgroundBackend(Protocol):
    async def enqueue(self, job_id: str) -> None: ...
    async def claim(self, *, block_ms: int = 1000) -> str | None: ...
    async def stop(self, job_id: str) -> None: ...
    async def requeue_lost(self, *, lease_ttl_s: float) -> list[str]: ...
    def events(self, job_id: str, last_event_id: int = 0) -> AsyncIterator[Any]: ...
    async def teardown(self) -> None: ...

b) Replace the if self._scaled: booleans with polymorphic calls. There are 7 of them in the facade. _scaled is really "is there a backend object", so a third backend would have to thread through the same boolean. The resume path is the clearest example — the facade currently reaches past the backend into job_service:

if self._scaled:
    if not await job_service.requeue_resumed_job(job_id, owner=self._owner):
        ...
else:
    await self._enqueue(...)

That would read better as a backend method (await self._backend.hand_back(job_id, owner=self._owner)), with the default path supplying an in-process implementation.

One design note we'd want agreement on before writing a JetStream backend: is the broker the queue of record, or just a dispatch hint? If JetStream only wakes workers and the DB CAS remains the real claim, every semantic in this PR holds unchanged and the new backend is a clean drop-in. If JetStream owns the queue, there are now two lease mechanisms — JetStream AckWait redelivery and the DB heartbeat/watchdog — which can disagree and redeliver a job a live worker still owns. That's exactly the drift class this PR removes by having one system of record, so we'd want to keep the DB as the authority and use the broker for dispatch and live event fan-out only.


Minor

  • stop() calls self._executor.stop() even in scaled mode, where start() never started it. I checked and it's a safe no-op (_workers is empty), so cosmetic only — but start() guards on self._scaled and stop() doesn't, which reads as an oversight.
  • Terminal status is written by execute_with_status and the terminal event by the runner's finally (and by requeue_lost for worker_lost). A crash between them leaves a terminal row with no terminal event. The events() grace pass handles it (the tail ends on status), so this is fine — flagging only to confirm it was deliberate.
  • retry_requeue_claim leaves stale owner/heartbeat_at in metadata when flipping back to QUEUED. Harmless because the next claimant's staleness check overwrites both, but worth a comment so nobody later keys off owner on a QUEUED row.

Things I checked that are correct

  • The claim CAS (claim_queued_lease) is sound — the heartbeat_at guard means two workers racing the same row produce exactly one rowcount == 1, including the prior_hb is None case for fresh submits.
  • Leaving the row QUEUED through the claim window is the right call: a worker that dies before starting leaves a re-claimable row instead of a stranded IN_PROGRESS.
  • requeue_lost's control flow is subtle but correct — the continue placement means a lost retry race falls through to "someone else requeued it" rather than double-failing.
  • Concurrent watchdogs can't append duplicate terminal milestones; the conditional flip is the single-flight token.
  • The resume path stamps a fresh heartbeat before the requeue, so the watchdog can't reap the row mid-handoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hey @dkaushik94, thanks for the review. Both bugs were real, good catches.

Fixed, taking your suggestion of branching on status instead of backend: supersede_suspended_runs now always goes through _cancel_suspended (which also fixes the unconditional success reporting), and stop_job checks SUSPENDED before the scaled branch, so the lost-claim fallthrough lands on the durable STOP for a run a resume just revived. The tests reproduce both failures first (row stuck SUSPENDED, unconsumed STOP left behind) and pass with the fix.

The pressure items:

  • Watchdog jitter: took your patch nearly verbatim (random initial offset, 0.75 to 1.25 spread per tick). On one holder per fleet, agreed the read amplification is real. I think leader election belongs with the retention work, both want the same "one process does periodic maintenance" primitive.
  • Candidates is now a setting (background_claim_candidates, default 5) with sizing guidance in its docstring. SKIP LOCKED stays the marked upgrade path.
  • Scaled on SQLite now logs a startup warning, not just the docstring note.
  • Retention: agreed this is the blocker. I'm filing the hardening ticket (retention/purge, queue metrics, zero-worker alerting) and it's the next PR after this one merges. I won't call scaled production-ready without it.
  • On NOTIFY, agree with your ordering and the one-listener-per-process constraint. A connection per SSE client would trade query load for connection exhaustion.

The seam: added a BackgroundBackend protocol with the DB-as-sole-system-of-record invariant in its docstring. For collapsing the _scaled booleans into polymorphic calls, agreed, and rather than fold that refactor into this diff mid-review I'll do it as a PR stacked right on top of this one, building the in-process backend object for the default path.

On your design question: the DB stays the queue of record. The broker is dispatch and event fan-out only, and the CAS stays the only claim. Two lease mechanisms that can disagree is exactly the drift this PR exists to remove, so a JetStream backend should be a clean drop-in under the protocol.

On the terminal status/event gap: it is deliberate (the events() grace pass ends tails on status, and there are tests pinning it), but you're right that a crash in that window leaves the event log permanently missing its terminal milestone, which matters for replay and timelines even though nothing hangs. I see three ways to treat it: leave it as an accepted crash artifact; teach the existing sweep to append the missing terminal event when it finds a terminal row without one (small, off the hot path, history self-heals); or extend the fail_queued_job single-transaction pattern to execute_with_status and fail_in_progress_job (structural, but it rewires the runner's finally path and adds seq contention to every run end). I lean toward the sweep heal. What do you think?

retry_requeue_claim now carries a comment saying owner/heartbeat_at are kept on purpose and not to key off owner on a QUEUED row, and stop() mirrors start()'s scaled guard.

log_level: str | None = typer.Option(None, help="Logging level."),
env_file: Path | None = typer.Option(None, help="Path to the .env file."),
idle_block_ms: int = typer.Option(
1000, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
),
Comment on lines +1003 to +1005

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Typer typer.Option min max integer range validation

💡 Result:

In Typer, you can validate numeric ranges for CLI options and arguments by passing min and max parameters directly to typer.Option or typer.Argument [1][2]. Typer handles these constraints automatically, displaying an error message if the input falls outside the specified bounds [1][3]. Example usage: from typing import Annotated import typer def main( user_id: Annotated[int, typer.Option(min=1, max=1000)]): print(f"ID is {user_id}") If you want to automatically adjust out-of-bounds values to the nearest valid boundary (minimum or maximum) instead of raising an error, you can enable the clamp parameter [1][3]: def main( rank: Annotated[int, typer.Option(min=1, max=10, clamp=True)]): print(f"Rank is {rank}") Because Typer is built on top of Click, these numeric validations are implemented via Click's range handling [4][5]. While you can use Click's explicit click.IntRange or click.FloatRange types within Typer, the idiomatic Typer approach is to use the min, max, and clamp parameters directly within the Annotated type hint [1][2]. For more complex validation logic that falls outside of simple range checking, you can implement custom parameter callbacks [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a '__main__.py$' . | sed 's#^\./##' | head -20

echo "== relevant lines =="
sed -n '830,865p' src/backend/base/langflow/__main__.py

echo "== dependency versions for typer/click =="
for f in pyproject.toml uv.lock poetry.lock requirements*.txt; do
  if [ -f "$f" ]; then
    echo "-- $f --"
    rg -n "typer|click" "$f" | head -40 || true
  fi
done

echo "== option signature context across file =="
rg -n "idle_block_ms|def .*worker|`@app.command`|typer\.Option|Annotated\[" src/backend/base/langflow/__main__.py | head -80

Repository: langflow-ai/langflow

Length of output: 18711


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== worker loop and idle usage =="
sed -n '865,898p' src/backend/base/langflow/__main__.py

echo "== claim implementation references =="
rg -n "async def .*claim|def .*claim|idle_block_ms|idle_block" src/backend -S | head -120

echo "== packages installed in sandbox =="
python3 - <<'PY'
try:
    import typer
    import click
    print("typer", typer.__version__)
    print("click", click.__version__)
    import inspect
    print("typer.Option signature:")
    print(inspect.signature(typer.Option))
    print("click types IntRange:")
    print(callable(getattr(click.types, "IntRange", None)))
except Exception as e:
    print("IMPORT_ERROR", repr(e))
PY

echo "== static verifier: option signatures have range constraints =="
python3 - <<'PY'
from pathlib import Path
import ast

path = Path("src/backend/base/langflow/__main__.py")
tree = ast.parse(path.read_text())

options_no_constraints = []
options_with_constraints = []

for n in ast.walk(tree):
    if isinstance(n, ast.arguments):
        for name in ("log_level", "env_file", "idle_block_ms"):
            for arg in n.args + n.kwonlyargs:
                if arg.arg == name:
                    defn = None
                    for p in n.defaults + n.kw_defaults:
                        if isinstance(p, ast.Call) and getattr(p.func, "attr", "") == "Option":
                            defn = p
                            break
                    if defn is None:
                        continue
                    constraints = []
                    for kw in defn.keywords:
                        if kw.arg in ("min", "max"):
                            constraints.append(kw.arg)
                    line = getattr(defn, "lineno", 0)
                    label = f"{arg.arg}: line {line}"
                    (options_with_constraints if constraints else options_no_constraints).append((label, constraints, ast.get_source_segment(path.read_text(), defn)))
                    break

print("with constraints:")
for item in options_with_constraints: print(item[0], item[1], item[2])
print("no constraints at typer.Option:")
for item in options_no_constraints: print(item[0], item[2].replace("\\n", " ")[:200])
PY

Repository: langflow-ai/langflow

Length of output: 10713


🌐 Web query:

pydantic v2 model_config validate_assignment false default value assigned to parameter __init__ extra ignore

💡 Result:

In Pydantic v2, the validate_assignment configuration and extra configuration (specifically extra='ignore') operate on different stages of the model lifecycle, and validate_assignment=False (which is the default) does not disable validation during model initialization [1][2][3]. 1. validate_assignment: This setting specifically controls whether validation occurs when an attribute is modified after the model instance has already been created [1][3]. It does not affect the initial validation that occurs when calling the model's constructor [1][3]. Even when set to False, Pydantic will still enforce all field validations during the initial instantiation [1][3]. 2. extra="ignore": When configured as model_config = ConfigDict(extra='ignore'), this setting dictates how the model handles keyword arguments passed to the constructor that do not correspond to defined model fields [2][4]. With 'ignore' (which is the default behavior), any such extra parameters are silently discarded and are not stored in the model instance [2][4]. In summary, setting validate_assignment=False does not stop Pydantic from validating required fields or types during the init call, nor does it affect how the model handles extra parameters [1][3]. If you pass extra parameters to a model with extra='ignore', they are simply dropped during initialization, regardless of your validate_assignment setting [2][4].

Citations:


Bound --idle-block-ms to a positive value.

This CLI option accepts 0 and negative values, and the worker loop passes it directly to claim(). With a non-positive poll window, claim() can return immediately when work is available and then sleep for zero, which makes the worker loop poll the shared job table without throttling. Add min=1 so Typer rejects invalid values.

🛡️ Proposed fix
     idle_block_ms: int = typer.Option(
-        1000, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
+        1000, min=1, help="Claim poll window in milliseconds (idle sleep между claim attempts)."
     ),
📝 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
idle_block_ms: int = typer.Option(
1000, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
),
idle_block_ms: int = typer.Option(
1000, min=1, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
),
🤖 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/__main__.py` around lines 848 - 850, Update the
idle_block_ms Typer option to enforce a minimum value of 1, so --idle-block-ms
rejects zero and negative inputs while preserving the existing default and help
text.

) -> None:
"""Run a Langflow background worker that drains queued jobs off the database.

Requires LANGFLOW_BACKGROUND_BACKEND=scaled and the same database as the
API. Each worker lease-claims QUEUED workflow jobs off the shared job table,
runs them through the background JobRunner (durable milestones land in
job_events, which any API replica's event tail reads), and releases the
lease. Run as many worker processes as you need horizontal capacity for.
"""
if env_file:
load_dotenv(env_file, override=True)
if log_level:
configure(log_level=log_level)

async def _run_worker() -> None:
from langflow.services.background_execution.worker import build_worker, run_worker_loop

await initialize_services()
settings = get_settings_service().settings
if not settings.background_backend_is_scaled:
typer.echo("LANGFLOW_BACKGROUND_BACKEND must be 'scaled' to run a worker.")
raise typer.Exit(code=1)

from uuid import uuid4

from langflow.services.deps import get_job_service

# Process-unique owner so this worker's heartbeats are attributable, and
# a periodic watchdog so a dead worker's in-flight job is reaped under a
# steady fleet WITHOUT requiring a restart.
owner = f"worker:{os.getpid()}:{uuid4().hex[:8]}"
backend, worker_runner, teardown = await build_worker(owner=owner)
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with suppress(NotImplementedError):
loop.add_signal_handler(sig, stop_event.set)
logger.info("Langflow worker started; draining queued jobs off the database.")
try:
await run_worker_loop(
backend,
worker_runner,
stop_event=stop_event,
idle_block_ms=idle_block_ms,
job_service=get_job_service(),
owner=owner,
lease_ttl_s=settings.background_lease_ttl_s,
watchdog_interval_s=settings.background_watchdog_interval_s,
)
finally:
await teardown()

asyncio.run(_run_worker())


@app.command()
def superuser(
username: str = typer.Option(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add_job_claim_scan_index

Revision ID: a4e8c2b6d931
Revises: 1d8d193028c8
Create Date: 2026-07-23 12:00:00.000000

Phase: EXPAND
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a4e8c2b6d931" # pragma: allowlist secret
down_revision: str | Sequence[str] | None = "1d8d193028c8" # pragma: allowlist secret
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_INDEX = "ix_job_claim_scan"


def upgrade() -> None:
"""Composite index for the scaled backend's claim poll and watchdog scan.

``claim_next_queued_lease`` filters ``status`` + ``type`` and sorts by
``created_timestamp`` on every worker poll; the watchdog scans
``status`` + ``type`` on its interval. ``ix_job_status_created`` cannot serve
either: the ``type`` filter sits between its two sorted columns. Without this
index the hottest query in the fleet is a scan+sort over every row retention
has not yet removed.
"""
conn = op.get_bind()
existing = {ix["name"] for ix in sa.inspect(conn).get_indexes("job")}
if _INDEX not in existing:
op.create_index(_INDEX, "job", ["status", "type", "created_timestamp"], unique=False)
Comment on lines +34 to +37

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 \
  'autocommit_block|CREATE INDEX CONCURRENTLY|postgresql_concurrently|create_index' \
  src/backend/base/langflow/alembic

rg -n -C 5 \
  'transaction_per_migration|transactional_ddl' \
  alembic.ini pyproject.toml src/backend

Repository: langflow-ai/langflow

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target migration =="
cat -n src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py

echo
echo "== alembic config migration settings =="
for f in alembic.ini pyproject.toml setup.py setup.cfg tox.ini; do
  if [ -f "$f" ]; then
    echo "-- $f --"
    rg -n 'transaction_per_migration|sqlalchemy.url|setenv|pythonpath|script_location|exclude_tag_prefix|sqlalchemy.url|sqlite|postgresql' "$f" || true
  fi
done

echo
echo "== references to job table in services/migration models =="
rg -n '"job"|table_name=.job|class .*Job|job\.(status|type|created_timestamp)|status.*type.*created|claim|lease|heartbeat' src/backend -g '*.py' | head -n 200

echo
echo "== create_index conventions using index existence checks =="
python3 - <<'PY'
from pathlib import Path
import re
target = Path('src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py')
text = target.read_text()
print('target file exists:', target.exists())
print('has get_bind:', 'op.get_bind()' in text)
print('has inspect get_indexes:', 'sa.inspect(conn).get_indexes("job")' in text)
print('has op.create_index on job:', bool(re.search(r'op\.create_index\s*\(\s*_INDEX\s*,\s*"job"', text)))
print('has concurrent keywords:', any(k in text for k in ['CREATE INDEX CONCURRENTLY', 'postgresql_concurrently', 'postgresql_with']))
PY

Repository: langflow-ai/langflow

Length of output: 27687


Build the job claim index concurrently for PostgreSQL.

op.create_index issues a normal CREATE INDEX, which holds locks that can block writes to the active job table while the index builds. Use a PostgreSQL-specific concurrent build outside an Alembic transaction block, with a safe non-PostgreSQL alternative, because normal PostgreSQL index creation blocks INSERT, UPDATE, and DELETE until it completes.

🤖 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/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py`
around lines 32 - 35, Update the upgrade logic around the existing index check
to use PostgreSQL’s concurrent index creation outside the Alembic transaction,
while retaining a safe non-PostgreSQL fallback using the normal index operation.
Preserve the existing _INDEX name, job table, columns, uniqueness, and
idempotent existing-index check.



def downgrade() -> None:
conn = op.get_bind()
existing = {ix["name"] for ix in sa.inspect(conn).get_indexes("job")}
if _INDEX in existing:
op.drop_index(_INDEX, table_name="job")
Loading
Loading