Skip to content

Commit 509b022

Browse files
committed
Add Memory Base API: models, migrations, service, endpoints, and tests
Introduces Memory Base (MB) — a per-flow knowledge base that auto-captures conversation history and ingests it into a Chroma vector store on configurable thresholds. Backend changes: - MemoryBase + MemoryBaseSession DB models with full CRUD - Three Alembic migrations: base tables, merge head, phase-2 fields (embedding_model, preprocessing, preproc_model, preproc_instructions) - MemoryBaseService: create/list/get/update/delete, session tracking, pending-message cursor logic, mismatch detection, regenerate - ingest_memory_task: async Chroma ingestion with cursor advance on success - REST API (/api/v1/memories): CRUD, flush, sessions, mismatch, regenerate - Flow output hook: on_flow_output() triggers auto-capture after each run - Plumbing in build.py, endpoints.py, workflow.py to call on_flow_output - deps.py: expose get_memory_base_service() - kb_helpers.py: FS/metadata helpers used by MB service Authored-By: Debojit Kaushik <kaushik.debojit@gmail.com> Add dedupe_key idempotency enforcement for MB ingestion jobs Centralizes idempotency into JobService.create_job() with a null-safe check, removing the redundant pre-flight logic from MemoryBaseService. Key changes: - services/jobs/exceptions.py: new DuplicateJobError(RuntimeError) — raised when a QUEUED/IN_PROGRESS/COMPLETED job with the same dedupe_key exists; FAILED/CANCELLED are retryable and are excluded - services/jobs/__init__.py: exports DuplicateJobError - services/jobs/service.py: null-guarded dedup query inside create_job() within the same session_scope as the insert (minimizes TOCTOU window) - services/database/models/jobs/model.py: dedupe_key field -> index=True - alembic/versions/36aa87831162: adds dedupe_key column + ix_job_dedupe_key index to job table with checkfirst guards - services/memory_base/service.py: updated key format to "ingestion:{mb_id}:{session_id}:{first_msg_id}" for namespace isolation; removed _has_non_retryable_job_for_dedupe_key and _has_active_job methods and all call sites; DuplicateJobError catch in _maybe_trigger() for silent skip on auto-capture; split regenerate() catch clauses - api/v1/memories.py: explicit DuplicateJobError catch before RuntimeError in flush_memory_base() for semantic clarity (both return 409) Co-Authored-By: Debojit Kaushik <kaushik.debojit@gmail.com> Checkpointing working version of MBs. TODO: User separation, Get messages endpoint, MB resumption midway through a chat for a session, tests. Added messages endpoint for Memory Bases. Added pagination to sessions endpoint. Modifed messages model to include ingestion related attributes. Added unit tests, fixed linting issues and formatting issues. Aligned Workflows API, /run endpoint, playground to all work with Memory Bases. Created DB models asociated with tracking memory base state with sessions and jobs. Added tests, created unit tests for service and task files related to MemoryBases. Improved concurrency of jobs, added (memory_base_id, session_id) locking to serialize jobs in case the job creation cadence moves ahead of ingestion jobs. Improved concurrency handling and moved the pending check to be live inside the ingestion job rather than a snapshot before triggering the job. Consolidated all migrations related to memory_bases into one idempotent version and serialized all migrations form release along with memory_bases for cleanliness and maintainability. Introduced advisory locking to address multi worker environment, added unique constraint to MB creation, added sanitization check to KB pathnames to avoid illegal directory creation. Added partial write rollback for ChromaDB, aligned same session is used for each job to avoid dangling advisory locks.
1 parent 0217f70 commit 509b022

32 files changed

Lines changed: 5456 additions & 93 deletions
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""add_memory_base_schema
2+
3+
Consolidates all Memory Base schema changes into a single migration:
4+
- job.dedupe_key (nullable String) + ix_job_dedupe_key
5+
- message.run_id (nullable UUID) + ix_message_run_id
6+
- message.is_output (bool, default false)
7+
- memory_base table + ix_memory_base_flow_id + ix_memory_base_user_id
8+
- memory_base_session table + three indexes
9+
- message_ingestion_record table + three indexes
10+
- memory_base_workflow_run table + two indexes
11+
12+
Phase: EXPAND
13+
14+
Revision ID: mb00a1b2c3d4
15+
Revises: d306e5c17c41
16+
Create Date: 2026-04-14 00:00:00.000000
17+
"""
18+
19+
from collections.abc import Sequence
20+
21+
import sqlalchemy as sa
22+
from alembic import op
23+
from langflow.utils import migration
24+
25+
# revision identifiers, used by Alembic.
26+
revision: str = "mb00a1b2c3d4" # pragma: allowlist secret
27+
down_revision: str | None = "d306e5c17c41" # pragma: allowlist secret
28+
branch_labels: str | Sequence[str] | None = None
29+
depends_on: str | Sequence[str] | None = None
30+
31+
32+
def upgrade() -> None:
33+
conn = op.get_bind()
34+
35+
# ------------------------------------------------------------------ #
36+
# job.dedupe_key #
37+
# ------------------------------------------------------------------ #
38+
inspector = sa.inspect(conn)
39+
existing_job_indexes = {idx["name"] for idx in inspector.get_indexes("job")}
40+
with op.batch_alter_table("job", schema=None) as batch_op:
41+
if not migration.column_exists("job", "dedupe_key", conn):
42+
batch_op.add_column(sa.Column("dedupe_key", sa.String(), nullable=True))
43+
if "ix_job_dedupe_key" not in existing_job_indexes:
44+
batch_op.create_index(batch_op.f("ix_job_dedupe_key"), ["dedupe_key"], unique=False)
45+
46+
# ------------------------------------------------------------------ #
47+
# message.run_id + message.is_output #
48+
# ------------------------------------------------------------------ #
49+
with op.batch_alter_table("message", schema=None) as batch_op:
50+
if not migration.column_exists("message", "run_id", conn):
51+
batch_op.add_column(sa.Column("run_id", sa.Uuid(), nullable=True))
52+
if not migration.column_exists("message", "is_output", conn):
53+
batch_op.add_column(sa.Column("is_output", sa.Boolean(), nullable=False, server_default=sa.text("false")))
54+
55+
existing_message_indexes = {idx["name"] for idx in sa.inspect(conn).get_indexes("message")}
56+
if "ix_message_run_id" not in existing_message_indexes:
57+
op.create_index("ix_message_run_id", "message", ["run_id"])
58+
59+
# ------------------------------------------------------------------ #
60+
# memory_base #
61+
# ------------------------------------------------------------------ #
62+
if not migration.table_exists("memory_base", conn):
63+
op.create_table(
64+
"memory_base",
65+
sa.Column("id", sa.Uuid(), nullable=False),
66+
sa.Column("name", sa.String(), nullable=False),
67+
sa.Column("flow_id", sa.Uuid(), nullable=False),
68+
sa.Column("user_id", sa.Uuid(), nullable=False),
69+
sa.Column("threshold", sa.Integer(), nullable=False, server_default=sa.text("50")),
70+
sa.Column("auto_capture", sa.Boolean(), nullable=False, server_default=sa.text("true")),
71+
sa.Column("embedding_model", sa.String(), nullable=False, server_default=sa.text("''")),
72+
sa.Column("preprocessing", sa.Boolean(), nullable=False, server_default=sa.text("false")),
73+
sa.Column("preproc_model", sa.String(), nullable=True),
74+
sa.Column("preproc_instructions", sa.String(), nullable=True),
75+
sa.Column("kb_name", sa.String(), nullable=False),
76+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
77+
sa.PrimaryKeyConstraint("id"),
78+
sa.UniqueConstraint("user_id", "name", name="uq_memory_base_user_name"),
79+
sa.Index("ix_memory_base_flow_id", "flow_id"),
80+
sa.Index("ix_memory_base_user_id", "user_id"),
81+
)
82+
83+
# ------------------------------------------------------------------ #
84+
# memory_base_session #
85+
# ------------------------------------------------------------------ #
86+
if not migration.table_exists("memory_base_session", conn):
87+
op.create_table(
88+
"memory_base_session",
89+
sa.Column("id", sa.Uuid(), nullable=False),
90+
sa.Column(
91+
"memory_base_id",
92+
sa.Uuid(),
93+
sa.ForeignKey("memory_base.id", ondelete="CASCADE"),
94+
nullable=False,
95+
),
96+
sa.Column("session_id", sa.String(), nullable=False),
97+
sa.Column("cursor_id", sa.Uuid(), nullable=True),
98+
sa.Column("total_processed", sa.Integer(), nullable=False, server_default=sa.text("0")),
99+
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
100+
sa.PrimaryKeyConstraint("id"),
101+
sa.UniqueConstraint("memory_base_id", "session_id", name="uq_memory_base_session"),
102+
)
103+
op.create_index("ix_memory_base_session_memory_base_id", "memory_base_session", ["memory_base_id"])
104+
op.create_index("ix_memory_base_session_session_id", "memory_base_session", ["session_id"])
105+
op.create_index(
106+
"ix_memory_base_session_lookup",
107+
"memory_base_session",
108+
["memory_base_id", "session_id"],
109+
)
110+
111+
# ------------------------------------------------------------------ #
112+
# message_ingestion_record #
113+
# ------------------------------------------------------------------ #
114+
if not migration.table_exists("message_ingestion_record", conn):
115+
op.create_table(
116+
"message_ingestion_record",
117+
sa.Column("id", sa.Uuid(), nullable=False),
118+
sa.Column(
119+
"message_id",
120+
sa.Uuid(),
121+
sa.ForeignKey("message.id", ondelete="CASCADE"),
122+
nullable=False,
123+
),
124+
sa.Column(
125+
"memory_base_id",
126+
sa.Uuid(),
127+
sa.ForeignKey("memory_base.id", ondelete="CASCADE"),
128+
nullable=False,
129+
),
130+
sa.Column(
131+
"job_id",
132+
sa.Uuid(),
133+
sa.ForeignKey("job.job_id", ondelete="SET NULL"),
134+
nullable=True,
135+
),
136+
sa.Column("session_id", sa.String(), nullable=False),
137+
sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False),
138+
sa.PrimaryKeyConstraint("id"),
139+
sa.UniqueConstraint(
140+
"message_id",
141+
"session_id",
142+
"memory_base_id",
143+
name="uq_mir_message_session_mb",
144+
),
145+
)
146+
op.create_index("ix_mir_message_id", "message_ingestion_record", ["message_id"])
147+
op.create_index("ix_mir_job_id", "message_ingestion_record", ["job_id"])
148+
op.create_index(
149+
"ix_mir_memory_base_session",
150+
"message_ingestion_record",
151+
["memory_base_id", "session_id"],
152+
)
153+
154+
# ------------------------------------------------------------------ #
155+
# memory_base_workflow_run #
156+
# ------------------------------------------------------------------ #
157+
if not migration.table_exists("memory_base_workflow_run", conn):
158+
op.create_table(
159+
"memory_base_workflow_run",
160+
sa.Column("id", sa.Uuid(), nullable=False),
161+
sa.Column(
162+
"memory_base_id",
163+
sa.Uuid(),
164+
sa.ForeignKey("memory_base.id", ondelete="CASCADE"),
165+
nullable=False,
166+
),
167+
sa.Column("session_id", sa.String(), nullable=False),
168+
sa.Column(
169+
"workflow_job_id",
170+
sa.Uuid(),
171+
sa.ForeignKey("job.job_id", ondelete="SET NULL"),
172+
nullable=True,
173+
),
174+
sa.Column(
175+
"ingestion_job_id",
176+
sa.Uuid(),
177+
sa.ForeignKey("job.job_id", ondelete="SET NULL"),
178+
nullable=True,
179+
),
180+
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
181+
sa.PrimaryKeyConstraint("id"),
182+
sa.UniqueConstraint(
183+
"memory_base_id",
184+
"session_id",
185+
"workflow_job_id",
186+
name="uq_mbwr_mb_session_wf_job",
187+
),
188+
)
189+
op.create_index("ix_mbwr_mb_session", "memory_base_workflow_run", ["memory_base_id", "session_id"])
190+
op.create_index("ix_mbwr_ingestion_job_id", "memory_base_workflow_run", ["ingestion_job_id"])
191+
192+
193+
def downgrade() -> None:
194+
conn = op.get_bind()
195+
196+
# Children first (FK dependencies) ----------------------------------- #
197+
if migration.table_exists("memory_base_workflow_run", conn):
198+
op.drop_index("ix_mbwr_ingestion_job_id", table_name="memory_base_workflow_run")
199+
op.drop_index("ix_mbwr_mb_session", table_name="memory_base_workflow_run")
200+
op.drop_table("memory_base_workflow_run")
201+
202+
if migration.table_exists("message_ingestion_record", conn):
203+
op.drop_index("ix_mir_memory_base_session", table_name="message_ingestion_record")
204+
op.drop_index("ix_mir_job_id", table_name="message_ingestion_record")
205+
op.drop_index("ix_mir_message_id", table_name="message_ingestion_record")
206+
op.drop_table("message_ingestion_record")
207+
208+
if migration.table_exists("memory_base_session", conn):
209+
op.drop_index("ix_memory_base_session_lookup", table_name="memory_base_session")
210+
op.drop_index("ix_memory_base_session_session_id", table_name="memory_base_session")
211+
op.drop_index("ix_memory_base_session_memory_base_id", table_name="memory_base_session")
212+
op.drop_table("memory_base_session")
213+
214+
if migration.table_exists("memory_base", conn):
215+
op.drop_index("ix_memory_base_user_id", table_name="memory_base")
216+
op.drop_index("ix_memory_base_flow_id", table_name="memory_base")
217+
op.drop_table("memory_base")
218+
219+
# Message column/index ----------------------------------------------- #
220+
existing_message_indexes = {idx["name"] for idx in sa.inspect(conn).get_indexes("message")}
221+
if "ix_message_run_id" in existing_message_indexes:
222+
op.drop_index("ix_message_run_id", table_name="message")
223+
with op.batch_alter_table("message", schema=None) as batch_op:
224+
if migration.column_exists("message", "is_output", conn):
225+
batch_op.drop_column("is_output")
226+
if migration.column_exists("message", "run_id", conn):
227+
batch_op.drop_column("run_id")
228+
229+
# Job column/index --------------------------------------------------- #
230+
with op.batch_alter_table("job", schema=None) as batch_op:
231+
existing_job_indexes = {idx["name"] for idx in sa.inspect(conn).get_indexes("job")}
232+
if "ix_job_dedupe_key" in existing_job_indexes:
233+
batch_op.drop_index(batch_op.f("ix_job_dedupe_key"))
234+
if migration.column_exists("job", "dedupe_key", conn):
235+
batch_op.drop_column("dedupe_key")

src/backend/base/langflow/api/build.py

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@
2929
from langflow.schema.message import ErrorMessage
3030
from langflow.schema.schema import OutputValue
3131
from langflow.services.database.models.flow.model import Flow
32-
from langflow.services.deps import get_chat_service, get_telemetry_service, session_scope
32+
from langflow.services.database.models.jobs.model import JobType
33+
from langflow.services.deps import (
34+
get_chat_service,
35+
get_job_service,
36+
get_memory_base_service,
37+
get_task_service,
38+
get_telemetry_service,
39+
session_scope,
40+
)
3341
from langflow.services.job_queue.service import JobQueueNotFoundError, JobQueueService
3442
from langflow.services.telemetry.schema import ComponentInputsPayload, ComponentPayload, PlaygroundPayload
3543

@@ -527,35 +535,81 @@ async def build_vertices(
527535
event_manager.on_error(data=error_message.data)
528536
raise
529537

538+
# Create a WORKFLOW job record so memory-base on_flow_output can track this run.
539+
# Best-effort: failures here must never break the build path.
540+
_build_job_svc = None
541+
_build_run_id: uuid.UUID | None = None
542+
try:
543+
_build_run_id = uuid.UUID(graph.run_id) if graph.run_id else None
544+
if _build_run_id is not None:
545+
_build_job_svc = get_job_service()
546+
await _build_job_svc.create_job(
547+
job_id=_build_run_id,
548+
flow_id=flow_id,
549+
user_id=current_user.id,
550+
job_type=JobType.WORKFLOW,
551+
)
552+
except Exception: # noqa: BLE001
553+
await logger.awarning(
554+
"Failed to create workflow job for /build — memory base tracking disabled for flow %s",
555+
flow_id,
556+
exc_info=True,
557+
)
558+
_build_job_svc = None
559+
530560
event_manager.on_vertices_sorted(data={"ids": ids, "to_run": vertices_to_run})
531561

532562
vertex_timedeltas: list[float] = []
533563
event_manager.on_build_start(data={})
534-
tasks = []
535-
for vertex_id in ids:
536-
task = asyncio.create_task(build_vertices(vertex_id, graph, event_manager, vertex_timedeltas))
537-
tasks.append(task)
538-
try:
539-
await asyncio.gather(*tasks)
540-
except asyncio.CancelledError:
541-
background_tasks.add_task(graph.end_all_traces_in_context())
542-
raise
543-
except Exception as e:
544-
await logger.aerror(f"Error building vertices: {e}")
545-
custom_component = graph.get_vertex(vertex_id).custom_component
546-
trace_name = getattr(custom_component, "trace_name", None)
547-
error_message = ErrorMessage(
548-
flow_id=flow_id,
549-
exception=e,
550-
session_id=graph.session_id,
551-
trace_name=trace_name,
552-
)
553-
event_manager.on_error(data=error_message.data)
554-
raise
564+
565+
async def _run_vertex_build() -> None:
566+
tasks = []
567+
for vertex_id in ids:
568+
task = asyncio.create_task(build_vertices(vertex_id, graph, event_manager, vertex_timedeltas))
569+
tasks.append(task)
570+
try:
571+
await asyncio.gather(*tasks)
572+
except asyncio.CancelledError:
573+
background_tasks.add_task(graph.end_all_traces_in_context())
574+
raise
575+
except Exception as e:
576+
await logger.aerror(f"Error building vertices: {e}")
577+
custom_component = graph.get_vertex(vertex_id).custom_component
578+
trace_name = getattr(custom_component, "trace_name", None)
579+
error_message = ErrorMessage(
580+
flow_id=flow_id,
581+
exception=e,
582+
session_id=graph.session_id,
583+
trace_name=trace_name,
584+
)
585+
event_manager.on_error(data=error_message.data)
586+
raise
587+
588+
if _build_job_svc and _build_run_id:
589+
await _build_job_svc.execute_with_status(_build_run_id, _run_vertex_build)
590+
else:
591+
await _run_vertex_build()
555592

556593
build_duration = sum(vertex_timedeltas)
557594
event_manager.on_end(data={"build_duration": build_duration})
558595
await graph.end_all_traces()
596+
597+
# Fire memory-base auto-capture hook — non-blocking background effect.
598+
# Must use fire_and_forget_task (not background_tasks.add_task) because
599+
# generate_flow_events runs as an asyncio task; by the time the flow
600+
# finishes, FastAPI has already drained the background_tasks queue and any
601+
# tasks added after that point are silently dropped.
602+
try:
603+
_run_id_uuid = uuid.UUID(graph.run_id) if graph.run_id else None # type-cast only; same run_id set on graph
604+
await get_task_service().fire_and_forget_task(
605+
get_memory_base_service().on_flow_output,
606+
flow_id=flow_id,
607+
session_id=graph.session_id or str(flow_id),
608+
job_id=_run_id_uuid,
609+
)
610+
except (RuntimeError, ValueError, OSError):
611+
await logger.awarning("Memory base hook scheduling failed for flow %s", flow_id, exc_info=True)
612+
559613
await event_manager.queue.put((None, None, time.time()))
560614

561615

src/backend/base/langflow/api/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
login_router,
1616
mcp_projects_router,
1717
mcp_router,
18+
memories_router,
1819
model_options_router,
1920
models_router,
2021
monitor_router,
@@ -68,6 +69,7 @@ def include_deployment_router(target_router: APIRouter) -> None:
6869
router_v1.include_router(projects_router)
6970
router_v1.include_router(starter_projects_router)
7071
router_v1.include_router(knowledge_bases_router)
72+
router_v1.include_router(memories_router)
7173
router_v1.include_router(mcp_router)
7274
router_v1.include_router(voice_mode_router)
7375
router_v1.include_router(mcp_projects_router)

0 commit comments

Comments
 (0)