perf: halve the database statements a flow run costs - #14580
Draft
ogabrielluiz wants to merge 3 commits into
Draft
perf: halve the database statements a flow run costs#14580ogabrielluiz wants to merge 3 commits into
ogabrielluiz wants to merge 3 commits into
Conversation
A flow run issued 27 SQL statements on the request-handling pool. It now issues 13. The largest block was the native tracer, which wrote its trace and every span with session.merge(). merge() must SELECT a primary key before it can choose between INSERT and UPDATE, and every one of those SELECTs missed: the trace id is minted with uuid4 when the run starts and span ids are uuid5 derived from it. That cost 2 + 2N statements for N spans, so a 50-component flow paid about 100 statements just to record itself. It is now one INSERT ... ON CONFLICT DO UPDATE per table: 12 statements down to 2, and span count no longer drives round-trips. The conflict clause is required rather than decorative, because a paused HITL run flushes partial spans and re-flushes the same deterministic ids after resume. The upsert needs two guards that merge() did not, both PostgreSQL-only: - Rows are collapsed on the primary key first. Span ids key on the component, so a Loop or a graph cycle produces the same id once per iteration, and PostgreSQL aborts a statement whose ON CONFLICT DO UPDATE would touch a row twice. The rollback discards the trace and every span, and wait_for_flush swallows the error, so the run succeeds with its trace missing. Last write wins, matching what the merge() loop produced. - Rows are chunked. PostgreSQL allows 65535 bind parameters per statement, which caps a single-statement flush at roughly 4681 spans. Three smaller reads went with it: - memory.py re-read every message it had just written. expire_on_commit is False and no column on message is filled in by the database, so the refresh returned what the caller already held. - update_job_status re-fetched the row after its UPDATE to satisfy a Job | None return. No caller binds the result, so it returns bool from rowcount. - The owning user now rides along on the api key lookup via joinedload instead of a second round-trip, on every authenticated request. Adds test_db_calls_per_run.py, which counts statements and pool checkouts with SQLAlchemy's own events. It overrides the two autouse fixtures that take the suite off the shipped configuration, because a per-run cost measured under test-only settings is not the cost users pay. The seven flush tests asserted mock_session.merge.call_count, so they broke on a change that kept behaviour identical. They now assert on rows read back from a real session.
The client fixture hardcoded a temporary SQLite file, so nothing in the backend suite could
exercise the backend most deployments actually run. It now honours LANGFLOW_TEST_DATABASE_URL
and falls back to the same SQLite path when that is unset, so default behaviour is unchanged.
This is worth having because the interesting failures do not exist on SQLite. PostgreSQL
enforces foreign keys immediately, refuses an ON CONFLICT DO UPDATE that would touch a row
twice, and caps a statement at 65535 bind parameters. All three are silent on SQLite.
docker run -d --name lf-pg -e POSTGRES_PASSWORD=lf -e POSTGRES_USER=lf \
-e POSTGRES_DB=lf -p 55432:5432 postgres:16-alpine
LANGFLOW_TEST_DATABASE_URL="postgresql://lf:lf@localhost:55432/lf" uv run pytest ...
Against PostgreSQL 16.14 the tracing, api-key and auth suites plus the per-run counter give
518 passed. A flow run costs 11 to 13 statements and 10 checkouts there, against 13 and 11 on
SQLite; the spread is the trace flush task occasionally settling after the response.
Known gap, not introduced here: three row-count assertions in test_messages.py fail on
PostgreSQL because the suite shares one database where each SQLite test gets a fresh file.
They fail identically with the source at the previous commit.
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Review of the previous two commits turned up one dead test, one measurement claim that was not true, and two portability problems. All four are fixed here. The tracer tests never ran on PostgreSQL. LANGFLOW_TEST_DATABASE_URL reached the client fixture only, and those tests take async_session, which was pinned to in-memory SQLite. So the tests guarding the two PostgreSQL-only defects were exactly the ones exempt from PostgreSQL. async_session now honours the same variable, and pointing it there immediately failed three tests SQLite had been hiding: two issue PRAGMA foreign_keys=ON, which is a syntax error on PostgreSQL and is now guarded by dialect, and one is described below. test_flush_collapses_repeated_builds_of_one_vertex now reproduces the real CardinalityViolation instead of only checking statement shape. test_flush_inserts_parent_before_child could not fail. It asserted both rows land and the child's FK resolves, which holds however the rows were ordered: they go out in one statement and both backends check an immediate FK at end of statement, so reversing the topological sort left it green. Order still matters across a chunk boundary, so it now asserts the order the statement carries. Verified by reversing the sort on both backends. The healthy-run counter only defended one of the four source changes; the other three fit under the ceiling's headroom. It now asserts per table -- no SELECT user, no SELECT job, at most one SELECT message, one INSERT span, no SELECT span -- so reverting any single change fails it. Each was checked by reverting that file alone to the base commit. The rejected-run counter had the same problem against a budget four times its measured cost, and now has its own. update_job_status returning bool had no test at all and now has one for both branches. The chunk budget was sized for PostgreSQL's 65535 bind parameters and ignored SQLite, which allows 999 before 3.32 -- a Python linked against an older distro libsqlite3 would have failed outright on a large trace where the per-row merge() could not. The budget is per dialect now: 3000 spans on SQLite peak at 896 bound parameters per statement. A third PostgreSQL defect surfaced and is deliberately not fixed: a malformed flow_id falls back to a sentinel uuid5 that matches no row, and trace.flow_id has a foreign key, so the fallback discards the trace it exists to preserve and wait_for_flush swallows the error. merge() hit the same foreign key, so it predates this work. It is documented in test_flush_invalid_flow_id_logs_error_and_continues. Also documents that LANGFLOW_TEST_DATABASE_URL costs the client fixture its per-test isolation, and drops the changelog prose that had accumulated in three docstrings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A flow run issued 27 SQL statements on the request-handling connection pool. It now issues 13.
Two PostgreSQL-only defects turned up on the way. Both silently destroy trace data, and both are reproduced against a real PostgreSQL 16.14 below.
Why this is worth more than the 52%
The halving is measured on a two-component flow. The change that matters is that a term which scaled with flow size is gone.
Persisting a trace cost
2 + 2Nstatements for N spans, where N is components plus LangChain callback spans:The benefit therefore grows with exactly the flows that were already the slowest. Per-run database cost is now roughly flat regardless of graph size.
What that buys:
Round-trips. On PostgreSQL every statement is a network hop, so this is about 14 fewer per run on a small flow and far more on a large one. For a flow with an LLM call that is noise next to the model latency; for cheap flows and large graphs it is not.
Write volume. Writes per run went from 12 to 8, which is less WAL, less replication lag and less vacuum pressure.
Concurrency headroom. Checkouts did not change, but each one now holds its connection for fewer statements, so the same pool serves more concurrent runs before it saturates.
Export volume. Database spans were around 80% of exported span volume, so this cuts APM cost as a side effect rather than by hiding the spans.
Worth being explicit about what is not measured here: this counts statements and round-trips. It is not a latency or throughput benchmark, and any millisecond figure above is arithmetic from round-trip counts rather than an observation.
Why the statements were there
The largest block was the native tracer.
NativeTracer._flush_to_databasewrote its trace row and every span withawait session.merge().merge()has toSELECTa primary key before it can decide betweenINSERTandUPDATE. Every one of thoseSELECTs missed: the trace id is minted withuuid4when the run starts, and span ids areuuid5values derived from it, so the row cannot already exist. That cost2 + 2Nstatements for N spans. A 50-component flow paid roughly 100 statements just to record itself.It is now one
INSERT ... ON CONFLICT DO UPDATEper table. 12 statements became 2, and span count no longer drives round-trips.The scaling fix is literally that the database call moved out of the per-span loop. It used to be:
and it is now
native.py:407-425, where the loop only builds objects and does no I/O:The loop that remains, in
_upsert_rowsatnative.py:85-93, iterates chunks rather than rows:so the statement count went from
Ntoceil(N / 2142), which is 1 for any realistic flow.The conflict clause is load-bearing rather than decorative. A paused HITL run flushes its partial spans (
langflow/api/build.py, comment "merge is idempotent on resume") and re-flushes the same deterministic ids after resume, so those rows have to be overwritten. A plainadd_all()would raise on the primary key.The two PostgreSQL defects
Neither exists on SQLite, which is why no test caught them.
A repeated vertex loses the whole trace. Span ids are
uuid5(namespace, f"{trace_id}-{component_id}"), keyed only on the component. A Loop component or a graph cycle traces the same vertex once per iteration, so one flush can carry the same primary key several times. PostgreSQL refuses to let a singleON CONFLICT DO UPDATEtouch a row twice and raisesCardinalityViolation, which rolls back the trace and every span with it.wait_for_flushswallows the error, so the run succeeds and the trace just disappears.merge()tolerated this through the identity map. Fixed by collapsing rows on the primary key before sending, last write wins, which is what themerge()loop produced.A flush past roughly 4681 spans fails entirely. PostgreSQL allows 65535 bind parameters per statement and a span row binds 14.
merge()sent one statement per row and had no such ceiling. Fixed by chunking, sized from the column count rather than a constant.Verified against PostgreSQL 16.14 with langflow's own models and its
postgresql+psycopgdriver:Three smaller reads
The post-commit message refresh.
memory.pyre-read every message it had just written. The session factory setsexpire_on_commit=False, and no column onmessageis filled in by the database, so thatSELECTreturned exactly what the caller already held. All 18 columns haveserver_default=None, there is no autoincrement primary key, and the five defaults (id,timestamp,error,edit,is_output) are Python-side and already evaluated before theINSERT.The job status read-back.
update_job_statusran itsUPDATE, then re-fetched the row by primary key to satisfy aJob | Nonereturn type. No caller binds the result, insrc/or in the tests. It returnsboolfromrowcountnow, which carries the only part a caller could have used.The separate user lookup.
session.get(User, ...)immediately after the api keySELECTis now ajoinedload. One statement instead of two, on every authenticated request rather than only on runs.Per-run ledger
Two-component flow, ChatInput to ChatOutput.
SELECT spanINSERT spanSELECT traceINSERT traceSELECT jobUPDATE jobINSERT jobSELECT messageINSERT messageUPDATE messageSELECT apikeySELECT userUPDATE apikeySELECT flowSELECT variableSELECT memory_basePool checkouts stayed at 11. Cutting statements does not cut checkouts: checkouts track session scopes, not statements.
How to measure it
It counts with SQLAlchemy's own
before_cursor_executeand poolcheckoutevents rather than with OpenTelemetry spans. No exporter, no Docker, no demo stack, about 10 seconds.Two corrections were needed before the number meant anything:
Two autouse fixtures in
tests/conftest.pytake the suite off the shipped configuration.disable_telemetry_writerforces the legacy synchronous write path anddeactivate_tracingswitches the internal trace and span tables off. Measured under those, a run looks like 30 statements with half of themvertex_buildandtransactionretention deletes, which is not what users pay. The harness overrides both.transactionandvertex_buildwrites do not touch the request pool in production. The telemetry writer owns a dedicated engine and amortizes their retention, so they are correctly absent from these counts.Running the suite on PostgreSQL
The
clientandasync_sessionfixtures hardcoded SQLite, so nothing in the backend suite could exercise the backend most deployments actually run. Both now honourLANGFLOW_TEST_DATABASE_URLand fall back to SQLite when it is unset, so default behaviour is unchanged.Both fixtures matter. Wiring only
clientleaves the tracer tests on SQLite, because they takeasync_session— which would have meant the tests guarding the two PostgreSQL defects were the ones never running on PostgreSQL. Pointing them at it immediately failed three that SQLite had been hiding: two issuedPRAGMA foreign_keys=ON, a syntax error there, and one asserted that a malformedflow_idstill persists its trace (see below). With those fixed,test_flush_collapses_repeated_builds_of_one_vertexnow reproduces the realCardinalityViolationon PostgreSQL instead of only checking statement shape on SQLite.Note the tradeoff on
client: with the variable set, every test shares one database rather than getting a fresh file, so run it serially and expect row-counting suites to need a clean database.async_sessionis unaffected, since it creates and drops the schema per test.A run costs 11 to 13 statements and 10 checkouts on PostgreSQL, against 13 and 11 on SQLite. The statement count varies because the trace flush is an asyncio task that sometimes settles just after the response and lands outside the measurement window. The variance is in the measurement, not in the work.
What a run should cost
Ten statements, derived from what a run has to do rather than picked as a round number: read the api key, read the flow, read the global variables, check memory-base, insert the job row, write its terminal status, write the messages (2), write the trace and its spans (2).
Today's 13 is three above that floor: the redundant
UPDATE jobtoin_progress, the pre-updateSELECT message, and theUPDATE apikeyusage counter.What the floor does not contain is anything scaling with flow size. That was the real problem, and it is gone.
Deliberately left alone
The api key usage counters. This is the last per-request write before the flow starts. On PostgreSQL it takes a row lock held to commit, so every concurrent request presenting the same key serializes on one row. Deployments using one key per service make that the common case.
total_usesandlast_used_atare read only by the API keys table in the frontend settings page. Nothing in authentication, rate limiting, or billing reads either, so they can be approximate.disable_track_apikey_usagealready exists and defaults toFalse. Flipping that default or batching the write both work, but it changes visible product behaviour, so it is not this PR's call.The 11 pool checkouts. Reducing them means merging session scopes, starting with the auth scope and the flow load, which run back to back in the same dependency chain. More invasive than anything here and it deserves its own measurement.
The latency tail. The 17% of checkouts over 50 ms was a SQLite contention property. On PostgreSQL the equivalent question is different and should be re-asked rather than re-run.
Tests
The seven tests in
TestFlushToDatabaseandTestFlushParentChildOrderassertedmock_session.merge.call_count, so they broke on a change that kept behaviour identical. They now assert on rows read back from a real session.Two were added.
test_flush_twice_upserts_instead_of_duplicatingcovers the HITL pause and resume shape.test_flush_collapses_repeated_builds_of_one_vertexcovers the duplicate span id case, and it asserts on the shape of the statement rather than on the stored rows so that it fails on SQLite too. Verified RED-GREEN: it fails with the collapse removed.On SQLite: tracing,
test_messages.py,services/database,test_api_key.py,services/authand the counter give 799 passed, 3 skipped.api/v2andservices/jobsgive 319 passed.background_executiongives 107 passed.A third PostgreSQL defect, pre-existing and not fixed here
Running the tracer tests on PostgreSQL surfaced one more, older than this change.
When a run has a malformed
flow_id, the flush falls back to a sentineluuid5, with the stated intent that "malformed flow_ids don't silently discard trace data". Buttrace.flow_idcarries a foreign key toflow.idand the sentinel matches no row by construction. PostgreSQL enforces that immediately, so the INSERT raises, the session rolls back, andwait_for_flushswallows the error. The fallback does the opposite of what its comment claims.merge()hit the same foreign key, so this predates the change and is left alone here. It is documented intest_flush_invalid_flow_id_logs_error_and_continues, whose persisted-row assertions now run only where foreign keys are off. Worth its own ticket.Two known failures, both confirmed pre-existing by checking the changed source files out at the previous commit and reproducing them identically:
test_endpoints.py::test_concurrent_stream_run_with_input_type_chaterrors on SQLite in a fixture.Three row-count assertions in
test_messages.pyfail on PostgreSQL because the suite shares one database where each SQLite test gets a fresh file, so rows accumulate across tests. Worth fixing if PostgreSQL runs become routine.