Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
3c39a64
test(background): real-instance test harness (hard_proof marker, sqli…
ogabrielluiz Jun 4, 2026
bb1b66f
fix(jobs): order get_jobs_by_flow_id by created_timestamp not created_at
ogabrielluiz Jun 4, 2026
27945c9
feat(jobs): add durable result and error columns to job table
ogabrielluiz Jun 4, 2026
5d4d9eb
feat(jobs): add job_events table and JobEvent model with unique (job_…
ogabrielluiz Jun 4, 2026
dca1a72
feat(jobs): add execution_signals table, ExecutionSignal model, Signa…
ogabrielluiz Jun 4, 2026
a064694
feat(jobs): add set_result and set_error durable writers to JobService
ogabrielluiz Jun 4, 2026
c573e4b
feat(jobs): add append_event (per-job seq) and read_events(after_seq)…
ogabrielluiz Jun 4, 2026
a42bc15
feat(jobs): add write_signal and unconsumed_signals to JobService
ogabrielluiz Jun 4, 2026
a133842
feat(jobs): add sweep_orphans startup reconcile to JobService
ogabrielluiz Jun 4, 2026
82e500c
test(jobs): hard-proof JobService store methods on real sqlite and po…
ogabrielluiz Jun 4, 2026
5c24ef0
feat(workflows): classify adapter frames as durable vs ephemeral
ogabrielluiz Jun 4, 2026
eb8e1f4
feat(settings): add background_max_concurrency and background_job_tim…
ogabrielluiz Jun 4, 2026
c1839d2
feat(workflows): add bounded in-process executor for background jobs
ogabrielluiz Jun 4, 2026
be46df4
feat(workflows): add in-memory live bus with durable replay reattach
ogabrielluiz Jun 4, 2026
e8279f6
feat(workflows): add background job runner driving adapter to durable…
ogabrielluiz Jun 4, 2026
a7d536c
feat(workflows): add BackgroundExecutionService facade, factory, and dep
ogabrielluiz Jun 4, 2026
f20c907
feat(workflows): route background mode through BackgroundExecutionSer…
ogabrielluiz Jun 4, 2026
b90b190
feat(workflows): sweep orphaned jobs on startup (requeue QUEUED, fail…
ogabrielluiz Jun 4, 2026
beee532
test(workflows): hard-proof durable background path on sqlite+postgre…
ogabrielluiz Jun 4, 2026
cf26d6b
fix(workflows): make stop deterministically win over racing completion
ogabrielluiz Jun 4, 2026
fa5d9c8
fix(jobs): retry append_event on seq collision and sqlite lock (gap-f…
ogabrielluiz Jun 4, 2026
c6c6ab5
feat(workflows): add events re-attach link to WorkflowJobResponse
ogabrielluiz Jun 4, 2026
b183fef
feat(workflows): persist submit request so re-enqueued QUEUED jobs re…
ogabrielluiz Jun 4, 2026
89bf7bd
fix(workflows): surface durable result/error on v2 status for backgro…
ogabrielluiz Jun 4, 2026
2506298
feat(workflows): add optional idempotency_key to v2 background submit
ogabrielluiz Jun 4, 2026
d3cf825
test(workflows): pin v2 background wire contract is additive-only (li…
ogabrielluiz Jun 4, 2026
461cc2f
test(workflows): guard dead in-memory background machinery stays deleted
ogabrielluiz Jun 4, 2026
1b2e748
test(workflows): adapt status-failed mock for additive error_detail; …
ogabrielluiz Jun 4, 2026
dcf7ed8
test(lfx): include idempotency_key in WorkflowRunRequest round-trip body
ogabrielluiz Jun 4, 2026
49c6b38
fix(background-execution): make worker cancel-handling Python 3.10 safe
ogabrielluiz Jun 4, 2026
c5e968a
fix(background-execution): answer reattach to terminal jobs from dura…
ogabrielluiz Jun 4, 2026
9f10932
fix(background-execution): enforce background_job_timeout on runs
ogabrielluiz Jun 4, 2026
d76e864
fix(background-execution): claim QUEUED jobs atomically in the startu…
ogabrielluiz Jun 4, 2026
de86276
fix(background-execution): stamp STOP signals consumed when acted on
ogabrielluiz Jun 4, 2026
d06020f
fix(background-execution): await in-flight job tasks on executor stop
ogabrielluiz Jun 4, 2026
2febd2a
fix(workflows): do not let a late /stop overwrite a finished job
ogabrielluiz Jun 4, 2026
219efa0
perf(background-execution): poll the STOP signal only on durable frames
ogabrielluiz Jun 4, 2026
aa872e5
test(background): add real-redis fixture keyed on LANGFLOW_TEST_REDIS…
ogabrielluiz Jun 4, 2026
84b6d23
feat(settings): add LANGFLOW_TEST_REDIS_URL and background_backend_is…
ogabrielluiz Jun 4, 2026
5ce9b19
feat(background): add RedisJobClaimQueue (LPUSH/BRPOPLPUSH lease-clai…
ogabrielluiz Jun 4, 2026
a236bde
test(background): prove BRPOPLPUSH blocking handoff against real redis
ogabrielluiz Jun 4, 2026
780672b
feat(background): add RedisBackgroundQueue (claim queue + DB replay +…
ogabrielluiz Jun 4, 2026
26886f3
test(background): prove cross-replica reattach over real redis Streams
ogabrielluiz Jun 4, 2026
a12004b
feat(background): lease watchdog requeues lost work (at-most-once def…
ogabrielluiz Jun 4, 2026
1539b7f
feat(background): stop() writes durable STOP signal + redis pub/sub f…
ogabrielluiz Jun 4, 2026
4d8c070
test(background): prove stop() pub/sub fast-path reaches the owning d…
ogabrielluiz Jun 4, 2026
8265afd
feat(background): add redis claim-and-run worker loop
ogabrielluiz Jun 4, 2026
c4f8c0a
feat(background): add RedisStreamLiveBus producer for cross-replica w…
ogabrielluiz Jun 4, 2026
c2170b6
feat(background): add WorkerJobRunner that hydrates and runs a durabl…
ogabrielluiz Jun 4, 2026
0f70af1
feat(background): add 'langflow worker' CLI command and build_worker …
ogabrielluiz Jun 4, 2026
ffdbd1b
feat(background): wire scaled redis backend into the facade + e2e cla…
ogabrielluiz Jun 4, 2026
57472d9
test(background): prove real worker-death watchdog and scaled stop->C…
ogabrielluiz Jun 4, 2026
ff70e10
test(background): real langflow worker OS subprocess hard proofs (e2e…
ogabrielluiz Jun 4, 2026
c4f31d5
test(background): head-to-head deltas (bounded pool, durability, swee…
ogabrielluiz Jun 4, 2026
fa33900
test(background): side-effect-safety crown jewel with a real side-eff…
ogabrielluiz Jun 4, 2026
4b99f8c
test(background): bounded-concurrency hard proof (N+k submitted, only…
ogabrielluiz Jun 4, 2026
e2be9f9
test(background): make concurrent-claim exactly-once proof determinis…
ogabrielluiz Jun 4, 2026
20f5c69
test(background): restore manager DB service + settings on subprocess…
ogabrielluiz Jun 4, 2026
c90fa34
test(background): kill worker process group on teardown + kill9 so 'u…
ogabrielluiz Jun 4, 2026
80eea66
test(background): drop unused processing-key constant from subprocess…
ogabrielluiz Jun 4, 2026
112340c
test(locust): add v2 background execution saturation locustfile
ogabrielluiz Jun 4, 2026
cdb5d4d
feat(background): add heartbeat/lease + atomic attempt store primitives
ogabrielluiz Jun 4, 2026
62d73d0
fix(background): scope idempotency_key dedupe per-user
ogabrielluiz Jun 4, 2026
fe77722
fix(background): liveness-aware + single-flight default orphan sweep
ogabrielluiz Jun 4, 2026
c67da5c
fix(background): lease-aware scaled reconcile + periodic watchdog + Q…
ogabrielluiz Jun 4, 2026
aada5d2
fix(background): terminal events on TIMED_OUT/CANCELLED + late-stop r…
ogabrielluiz Jun 4, 2026
784e5f4
fix(background): default re-enqueue lease-claims QUEUED without flipp…
ogabrielluiz Jun 4, 2026
2e0e5e1
test(background): explicit no-double-run-under-concurrent-reconcile p…
ogabrielluiz Jun 4, 2026
c2dee9d
fix(background): unify Last-Event-ID namespace to durable seq for liv…
ogabrielluiz Jun 4, 2026
b8209fb
fix(background): dedup durable milestones at the cross-replica reatta…
ogabrielluiz Jun 4, 2026
698167c
fix(background): make durable replay byte-identical to live per protocol
ogabrielluiz Jun 4, 2026
aef66a3
fix(background): bound InMemoryLiveBus._closed so it does not leak pe…
ogabrielluiz Jun 4, 2026
7b0214a
fix(background): close the scaled backend redis client on facade tear…
ogabrielluiz Jun 4, 2026
b3b71b6
fix(background): batch the scaled live-bus stream TTL refresh, not pe…
ogabrielluiz Jun 4, 2026
895dbb4
fix(background): bound _closed via LRU, preserving reattach-to-closed…
ogabrielluiz Jun 4, 2026
6765573
fix(background): drop the dead pub/sub stop fast-path, durable poll i…
ogabrielluiz Jun 4, 2026
d19ab3d
fix(background): redact inline globals from the persisted replay request
ogabrielluiz Jun 4, 2026
2e66b4a
fix(background): close attempt-cap race by bumping attempt and flippi…
ogabrielluiz Jun 4, 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
61 changes: 59 additions & 2 deletions .github/workflows/migration-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:
- 'src/backend/base/langflow/services/database/service.py'
- 'src/backend/tests/unit/alembic/**'
- '.github/workflows/migration-validation.yml'
- 'src/backend/base/langflow/services/background_execution/**'
- 'src/backend/base/langflow/services/jobs/**'
- 'src/backend/base/langflow/services/job_queue/**'
- 'src/backend/tests/unit/background_execution/**'

jobs:
model-migration-consistency:
Expand All @@ -19,7 +23,7 @@ jobs:
image: postgres:16
env:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow
POSTGRES_PASSWORD: langflow # pragma: allowlist secret
POSTGRES_DB: langflow
ports:
- 5432:5432
Expand Down Expand Up @@ -50,10 +54,63 @@ jobs:
- name: Check model/migration consistency
env:
MIGRATION_VALIDATION_CI: "true"
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow"
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow" # pragma: allowlist secret
run: |
uv run pytest src/backend/tests/unit/alembic/test_migration_execution.py -x -v

background-hard-proof:
name: Background Execution Hard-Proof (real Postgres + Redis)
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16
env:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow # pragma: allowlist secret
POSTGRES_DB: langflow
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U langflow"
--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
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: Install dependencies
run: |
uv sync --extra postgresql

- name: Run hard-proof tests
env:
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow" # pragma: allowlist secret
LANGFLOW_TEST_REDIS_URL: "redis://localhost:6379/0"
run: |
uv run pytest src/backend/tests/unit/background_execution -m hard_proof -x -v

validate-migration:
name: Migration Pattern Validation
runs-on: ubuntu-latest
Expand Down
6 changes: 3 additions & 3 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -2831,15 +2831,15 @@
"filename": "src/backend/tests/conftest.py",
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
"is_verified": false,
"line_number": 480,
"line_number": 484,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "src/backend/tests/conftest.py",
"hashed_secret": "61fbb5a12cd7b1f1fe1624120089efc0cd299e43",
"is_verified": false,
"line_number": 690,
"line_number": 694,
"is_secret": false
}
],
Expand Down Expand Up @@ -9281,5 +9281,5 @@
}
]
},
"generated_at": "2026-05-29T22:28:26Z"
"generated_at": "2026-06-04T01:58:46Z"
}
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ unit_tests: ## run unit tests
unit_tests_looponfail:
@make unit_tests args="-f"

hard_proof_tests: ## run real-instance hard-proof tests (needs LANGFLOW_TEST_DATABASE_URI + LANGFLOW_TEST_REDIS_URL)
@uv sync --frozen
uv run pytest src/backend/tests/unit \
--ignore=src/backend/tests/integration \
--ignore=src/backend/tests/unit/template \
-m hard_proof -ra $(args)

lfx_tests: ## run lfx package unit tests
@echo 'Running LFX Package Tests...'
@cd src/lfx && \
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ markers = [
"unit: Unit tests",
"integration: Integration tests",
"slow: Slow-running tests",
"security: Security regression tests (IDOR, auth, access control)"
"security: Security regression tests (IDOR, auth, access control)",
"hard_proof: Real-instance proof tests (real SQLite + real Postgres + real Redis)"
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Expand Down
59 changes: 59 additions & 0 deletions src/backend/base/langflow/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,65 @@ def print_banner(host: str, port: int, protocol: str) -> None:
logger.info(f"Open Langflow: {protocol}://{access_host}:{port}")


@app.command()
def worker(
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 blocking-pop window in milliseconds."),
) -> None:
"""Run a Langflow background worker that drains the redis job-claim queue.

Requires LANGFLOW_JOB_QUEUE_TYPE=redis. Each worker claims queued jobs, runs
them through the background JobRunner (publishing live frames to redis
Streams so any API replica can reattach), 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_JOB_QUEUE_TYPE must be 'redis' 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 the redis job-claim queue.")
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 result and error columns to job table.

Revision ID: 185482a2d715
Revises: b7c4d8e9f012
Create Date: 2026-06-03 10:00:00.000000

Phase: EXPAND
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "185482a2d715" # pragma: allowlist secret
down_revision: str | None = "b7c4d8e9f012" # pragma: allowlist secret
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")


def upgrade() -> None:
conn = op.get_bind()
existing_columns = {col["name"] for col in sa.inspect(conn).get_columns("job")}

with op.batch_alter_table("job", schema=None) as batch_op:
if "result" not in existing_columns:
batch_op.add_column(sa.Column("result", _JSON, nullable=True))
if "error" not in existing_columns:
batch_op.add_column(sa.Column("error", _JSON, nullable=True))


def downgrade() -> None:
conn = op.get_bind()
existing_columns = {col["name"] for col in sa.inspect(conn).get_columns("job")}

with op.batch_alter_table("job", schema=None) as batch_op:
if "error" in existing_columns:
batch_op.drop_column("error")
if "result" in existing_columns:
batch_op.drop_column("result")
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""add execution_signals table for cooperative job control.

Revision ID: 8ce44e4858c6
Revises: b026885b89c8
Create Date: 2026-06-03 10:10:00.000000

Phase: EXPAND
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "8ce44e4858c6" # pragma: allowlist secret
down_revision: str | None = "b026885b89c8" # pragma: allowlist secret
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")


def upgrade() -> None:
from langflow.utils import migration

conn = op.get_bind()
if not migration.table_exists("execution_signals", conn):
op.create_table(
"execution_signals",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("job_id", sa.Uuid(), nullable=False),
sa.Column(
"signal_type",
sa.Enum("stop", name="execution_signal_type_enum"),
nullable=False,
),
sa.Column("data", _JSON, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("execution_signals", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_execution_signals_id"), ["id"], unique=False)
batch_op.create_index(batch_op.f("ix_execution_signals_job_id"), ["job_id"], unique=False)


def downgrade() -> None:
from langflow.utils import migration

conn = op.get_bind()
if migration.table_exists("execution_signals", conn):
op.drop_table("execution_signals")
# Drop the postgres enum type explicitly; sqlite has no standalone enum type.
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(name="execution_signal_type_enum").drop(bind, checkfirst=True)
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""add job_events table for durable background-job event log.

Revision ID: b026885b89c8
Revises: 185482a2d715
Create Date: 2026-06-03 10:05:00.000000

Phase: EXPAND
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "b026885b89c8" # pragma: allowlist secret
down_revision: str | None = "185482a2d715" # pragma: allowlist secret
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")


def upgrade() -> None:
from langflow.utils import migration

conn = op.get_bind()
if not migration.table_exists("job_events", conn):
op.create_table(
"job_events",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("job_id", sa.Uuid(), nullable=False),
sa.Column("seq", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(), nullable=False),
sa.Column("payload", _JSON, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("job_id", "seq", name="uq_job_events_job_id_seq"),
)
with op.batch_alter_table("job_events", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_job_events_id"), ["id"], unique=False)
batch_op.create_index(batch_op.f("ix_job_events_job_id"), ["job_id"], unique=False)


def downgrade() -> None:
from langflow.utils import migration

conn = op.get_bind()
if migration.table_exists("job_events", conn):
op.drop_table("job_events")
10 changes: 10 additions & 0 deletions src/backend/base/langflow/api/v2/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ def terminal_error_type(self) -> str | None:
via event type; the buffer task falls back to other signals.
"""

def is_durable(self, event_type: str) -> bool:
"""True when a frame of ``event_type`` must be persisted to the durable log.

Durable frames (milestones) are appended to ``job_events`` so a
reattaching client can rebuild state after the live bus is gone.
Ephemeral frames (token deltas) are published to the live bus only.
Unknown types default to ephemeral — a new milestone must opt in
explicitly rather than silently bloat the durable log.
"""


StreamAdapterFactory = Callable[[StreamAdapterContext], StreamAdapter]

Expand Down
Loading
Loading