Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions agent/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add is_partial to table_profiles

Revision ID: a123e456b789
Revises: f9a3d1c8e205
Create Date: 2026-07-05 12:00:00.000000

"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a123e456b789"
down_revision: Union[str, None] = "f9a3d1c8e205"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Add is_partial column to table_profiles table
op.add_column(
"table_profiles",
sa.Column("is_partial", sa.Boolean(), nullable=False, server_default="false")
)


def downgrade() -> None:
op.drop_column("table_profiles", "is_partial")
3 changes: 3 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ def override_trino_settings(self) -> "Settings":
LANGFUSE_WAIT_MAX_ATTEMPTS: int = 20
LANGFUSE_WAIT_INITIAL_DELAY_SECS: float = 0.5
LANGFUSE_WAIT_BACKOFF_FACTOR: float = 1.5
# Concurrency and Trino
PROFILER_MAX_CONCURRENT_QUERIES: int = 10
PROFILING_CHUNK_SIZE: int = 5
TEMPORAL_HOST: str = "localhost:7233"

# JWT Config
JWT_SECRET: str = "dev-secret-change-in-production"
Expand Down
3 changes: 2 additions & 1 deletion backend/app/infra_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,7 @@ def _ensure_airlines_registered() -> None:
)
from sqlmodel import Session, select

from app.services.profiling_engine import run_table_profiling
from core.services.profiling_engine import run_table_profiling

logger.info("[InfraInit] Registering airlines Snowflake tables...")

Expand Down Expand Up @@ -1192,6 +1192,7 @@ def _run_profile(
if result.success
else ProfilingStatus.failed
)
profile.is_partial = not result.success or bool(result.errors)
profile.version = result.version
profile.row_count = result.row_count
profile.sample_size = result.sample_size
Expand Down
117 changes: 107 additions & 10 deletions backend/app/routers/profiling.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import traceback
from datetime import datetime, timedelta
from typing import Any
import anyio
import anyio.to_thread
from temporalio.client import Client, WorkflowExecutionStatus
from app.config import settings

from core.db.engine import engine, get_session
from core.models.models import (
Expand All @@ -26,7 +30,7 @@
from sqlmodel import Session, select

from app.services.join_detection import discover_joins_for_table
from app.services.profiling_engine import (
from core.services.profiling_engine import (
build_context_for_llm,
generate_table_summary,
run_table_profiling,
Expand Down Expand Up @@ -74,10 +78,36 @@ def _upsert_ai_summary(session: Session, table_id: str, summary: str) -> None:


# ── Background worker ──────────────────────────────────────────────────────────
def _run_profile_job(table_id: str):
# ── Background worker ──────────────────────────────────────────────────────────
async def trigger_temporal_profiling_workflow(table_id: str, resume_from_partial: bool = False) -> bool:
"""
Attempts to trigger the profiling workflow via Temporal.
Returns True if started successfully, False otherwise.
"""
try:
logger.info("[Profiling] Connecting to Temporal client at %s", settings.TEMPORAL_HOST)
client = await Client.connect(settings.TEMPORAL_HOST)
await client.start_workflow(
"TableProfilingWorkflow",
id=f"profile-{table_id}",
task_queue="profiling-tasks",
args=[table_id, resume_from_partial],
)
logger.info("[Profiling] Successfully started Temporal workflow for table %s", table_id)
return True
except Exception as e:
if "WorkflowAlreadyStartedError" in str(type(e)) or "WorkflowExecutionAlreadyStartedError" in str(type(e)):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can't you just check the type like
except (WorkflowAlreadyStartedError, WorkflowExecutionAlreadyStartedError):

logger.info("[Profiling] Profiling workflow is already running for table %s", table_id)
return True
logger.error("[Profiling] Failed to start Temporal workflow for table %s: %s", table_id, e)
return False


def _run_profile_job_local(table_id: str):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think if we set it to run at temporal, we dont want option to run that local, just throw an error so the user know that currently can't do profiling.
If you want local lets do it like we did in the esca with the env LOCAL_PROFILE (but not sure we really want profiling to be on backend anymore)

"""
Background task: runs real Trino profiling via profiling_engine,
then upserts results into table_profiles + column_profiles.
Fallback when Temporal is not available.
"""
with Session(engine) as session:
table = session.get(Table, table_id)
Expand All @@ -104,10 +134,6 @@ def _run_profile_job(table_id: str):
logger.error(f"[Profiling] Engine failed for {table_id}: {exc}")
return

if not result.success:
logger.error(f"[Profiling] Engine unsuccessful for {table_id}")
return

# Persist results (Upsert by table_id)
with Session(engine) as session:
profile = session.exec(
Expand All @@ -118,6 +144,7 @@ def _run_profile_job(table_id: str):
session.add(profile)

profile.status = ProfilingStatus.completed
profile.is_partial = not result.success or bool(result.errors)
profile.row_count = result.row_count
profile.sample_size = result.sample_size
profile.column_count = result.column_count
Expand Down Expand Up @@ -178,9 +205,16 @@ def _run_profile_job(table_id: str):
logger.warning("[Profiling] AI summary step failed for %s: %s", table_id, exc)


async def _trigger_profiling(table_id: str, resume_from_partial: bool = False):
success = await trigger_temporal_profiling_workflow(table_id, resume_from_partial)
if not success:
logger.info("Falling back to local FastAPI background task profiling for %s", table_id)
await anyio.to_thread.run_sync(_run_profile_job_local, table_id)


# ── GET /tables/{id}/profile ───────────────────────────────────────────────────
@router.get("/tables/{table_id}/profile", response_model=TableProfileRead)
def get_table_profile(table_id: str, session: Session = Depends(get_session)):
async def get_table_profile(table_id: str, session: Session = Depends(get_session)):
table = session.get(Table, table_id)
if not table:
raise HTTPException(status_code=404, detail="Table not found")
Expand All @@ -194,6 +228,31 @@ def get_table_profile(table_id: str, session: Session = Depends(get_session)):
status_code=404, detail="No profile found. Run POST /profile/run first."
)

# Sync state from Temporal if stuck
if profile.status in (ProfilingStatus.running, ProfilingStatus.pending):
try:
client = await Client.connect(settings.TEMPORAL_HOST)
handle = client.get_workflow_handle(f"profile-{table_id}")
desc = await handle.describe()
if desc.status in (
WorkflowExecutionStatus.FAILED,
WorkflowExecutionStatus.TERMINATED,
WorkflowExecutionStatus.TIMED_OUT,
WorkflowExecutionStatus.CANCELED
):
profile.status = ProfilingStatus.failed
session.add(profile)
session.commit()
session.refresh(profile)
except Exception as e:
if "NotFound" in str(type(e)):
profile.status = ProfilingStatus.failed
session.add(profile)
session.commit()
session.refresh(profile)
else:
logger.warning("[Profiling] Failed to sync temporal status for %s: %s", table_id, e)

Comment on lines +231 to +255

Copy link
Copy Markdown

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

echo "== profiling.py around relevant lines =="
sed -n '200,280p' backend/app/routers/profiling.py

echo
echo "== Temporal-related imports/usages =="
rg -n "RPCError|RPCStatusCode|NotFound|describe\(\)|get_workflow_handle|Client.connect|WorkflowExecutionStatus" backend -S

Repository: StavPonte11/text2sql-onboarding

Length of output: 4502


🌐 Web query:

temporalio Python RPCError NOT_FOUND workflow not found describe get_workflow_handle documentation

💡 Result:

In the Temporal Python SDK, an RPCError with the status NOT_FOUND indicates that the requested Workflow Execution could not be found by the server [1]. Understanding Workflow Handles and NOT_FOUND It is important to note that the method Client.get_workflow_handle does not perform a network call to the Temporal server [2]. It simply creates a local object (a handle) that represents the intent to interact with a specific Workflow ID [3][4]. The NOT_FOUND error is typically raised only when you invoke an operation on that handle that requires communication with the server, such as: - Calling describe to fetch the current status of the Workflow [4]. - Sending a Signal, Query, or Update to the Workflow [1]. - Requesting the result of the Workflow [3][4]. - Attempting to terminate or cancel the Workflow [3]. Common Causes for NOT_FOUND If you receive this error, it generally means the Temporal server does not recognize the specific Workflow Execution: 1. Workflow Does Not Exist: The Workflow ID provided does not correspond to any known, active, or closed workflow in the current namespace [1][2]. 2. Workflow Completed or Expired: The Workflow may have finished or been terminated. If the Workflow has passed its retention period (the time history is kept in the database), it may no longer be available for retrieval via standard API calls [5]. 3. Execution Status Mismatch: In some cases, a workflow might appear in a visibility index but be missing from primary persistence (e.g., due to a data synchronization issue in a self-hosted environment), leading to conflicts where the workflow cannot be described or terminated but also cannot be restarted [6][7]. 4. Namespace Mismatch: Ensure that the Client is connected to the correct namespace where the Workflow was originally started [6]. Handling the Error To handle this exception in your Python code, you can catch the RPCError and inspect the status code [8][1]: from temporalio.client import RPCError, RPCStatusCode try: await handle.describe except RPCError as e: if e.status == RPCStatusCode.NOT_FOUND: print("Workflow not found.") else: raise

Citations:


Handle Temporal NOT_FOUND explicitly

handle.describe() raises an RPCError with status == NOT_FOUND when the workflow is missing; checking str(type(e)) will never match, so stale running/pending profiles stay stuck instead of being marked failed. Catch the RPC error and branch on its status code here.

🤖 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 `@backend/app/routers/profiling.py` around lines 231 - 255, The Temporal sync
block in the profiling router is checking the wrong exception shape, so missing
workflows are not being detected and stuck profiles remain running or pending.
Update the try/except around Client.connect, get_workflow_handle, and
handle.describe to catch the RPCError from Temporal and branch on its status
code for NOT_FOUND, then mark the profile as failed and persist it; keep the
existing failure handling for other workflow terminal states and log unexpected
errors through the existing logger.warning path.

return profile


Expand All @@ -211,8 +270,7 @@ def run_all_profiles(
tables = session.exec(select(Table)).all()
count = 0
for table in tables:
# If not force, check for running profile
background_tasks.add_task(_run_profile_job, str(table.id))
background_tasks.add_task(_trigger_profiling, table.id)
count += 1

logger.info(
Expand All @@ -233,6 +291,7 @@ def run_table_profile(
table_id: str,
background_tasks: BackgroundTasks,
force: bool = False,
resume_from_partial: bool = False,
session: Session = Depends(get_session),
):
"""
Expand All @@ -248,16 +307,54 @@ def run_table_profile(
select(TableProfile).where(TableProfile.table_id == table_id)
).first()

background_tasks.add_task(_run_profile_job, table_id)
background_tasks.add_task(_trigger_profiling, table_id, resume_from_partial)
logger.info(f"[Profiling] Queued profiling job: table={table_id}")

if profile:
# Optimistically update to pending
profile.status = ProfilingStatus.pending
session.add(profile)
session.commit()
session.refresh(profile)
return profile

# Return a dummy pending profile just to satisfy response_model if not exists yet
return TableProfile(table_id=table_id, status=ProfilingStatus.pending)


# ── POST /tables/{id}/profile/terminate ───────────────────────────────────────
@router.post("/tables/{table_id}/profile/terminate", status_code=200)
async def terminate_table_profile(table_id: str, session: Session = Depends(get_session)):
"""
Terminates a running Temporal profiling workflow for the given table.
"""
table = session.get(Table, table_id)
profile = session.exec(
select(TableProfile).where(TableProfile.table_id == table_id)
).first()

if not table or not profile or profile.status not in (ProfilingStatus.running, ProfilingStatus.pending):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure but we don't change to running or pending no? Because if we already have a completed profile, when we run again it won't be running or pending because we want to save the previous one (only save the latest in DB)

raise HTTPException(status_code=404, detail="Table not found")

try:
client = await Client.connect(settings.TEMPORAL_HOST)
handle = client.get_workflow_handle(f"profile-{table_id}")
await handle.cancel()
except Exception as e:
logger.warning(f"Failed to cancel temporal workflow for {table_id}: {e}")

profile = session.exec(
select(TableProfile).where(TableProfile.table_id == table_id)
).first()

if profile:
profile.status = ProfilingStatus.failed
session.add(profile)
session.commit()

return {"message": "Profiling job terminated", "status": "failed"}

Comment on lines +326 to +356

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== profiling router excerpt ==\n'
sed -n '300,380p' backend/app/routers/profiling.py

printf '\n== search TableProfilingWorkflow and status updates ==\n'
rg -n "TableProfilingWorkflow|is_partial|ProfilingStatus|status =" backend/app -S

printf '\n== search frontend polling/termination logic ==\n'
rg -n "profile/terminate|ProfilingStatus.running|ProfilingStatus.pending|status.*running|status.*pending|poll" frontend backend -S

Repository: StavPonte11/text2sql-onboarding

Length of output: 13063


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== profiling workflow/status update path ==\n'
sed -n '120,170p' backend/app/routers/profiling.py

printf '\n== profiling terminate path around line 232 ==\n'
sed -n '220,255p' backend/app/routers/profiling.py

printf '\n== frontend profiling polling logic ==\n'
sed -n '1338,1375p' frontend/src/components/tables/ProfilingTab.tsx

Repository: StavPonte11/text2sql-onboarding

Length of output: 4738


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== profile read path ==\n'
sed -n '1,120p' backend/app/routers/profiling.py

printf '\n== frontend profile query source ==\n'
sed -n '1,140p' frontend/src/components/tables/ProfilingTab.tsx

printf '\n== backend profile endpoints ==\n'
rg -n "GET /tables/\{table_id\}/profile|profileQ|invalidateQueries\\(\\{ queryKey: \\['profile'" backend/app frontend/src -S

Repository: StavPonte11/text2sql-onboarding

Length of output: 8844


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== infra_init profiling status write ==\n'
sed -n '1148,1210p' backend/app/infra_init.py

printf '\n== workflow/task references ==\n'
rg -n "TableProfilingWorkflow|profile-\\{table_id\\}|cancel\\(|terminated|canceled|failed|completed|is_partial" backend/app/infra_init.py backend/app/routers/profiling.py -S

Repository: StavPonte11/text2sql-onboarding

Length of output: 6122


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== workflow definition search ==\n'
rg -n "`@workflow`\.defn|class .*Workflow|TableProfilingWorkflow|workflow\.run|start_workflow\(" backend -S

printf '\n== profile persistence writes ==\n'
rg -n "profile\.status = ProfilingStatus\.(completed|failed)|session\.commit\(\)|run_table_profiling\(" backend/app -S

Repository: StavPonte11/text2sql-onboarding

Length of output: 4798


Surface cancellation failures instead of unconditionally marking the profile failed. handle.cancel() can fail, but this endpoint still logs the error, flips profile.status to failed, and returns 200. That leaves the API/UI treating profiling as terminated even when the Temporal workflow may still be running.

🤖 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 `@backend/app/routers/profiling.py` around lines 326 - 356, The
terminate_table_profile endpoint is swallowing cancellation failures and always
marking the TableProfile as failed, even when
Client.connect/get_workflow_handle/handle.cancel does not succeed. Update
terminate_table_profile to surface the exception path from the Temporal cancel
attempt (instead of continuing to success), and only set profile.status to
failed and return the terminated response after a confirmed cancel. Keep the
flow tied to the existing terminate_table_profile, TableProfile, and
handle.cancel logic so the API does not report success when the workflow may
still be running.


# ── GET /tables/{id}/profile/columns ──────────────────────────────────────────
@router.get(
"/tables/{table_id}/profile/columns", response_model=list[ColumnProfileRead]
Expand Down
2 changes: 1 addition & 1 deletion backend/e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from sqlmodel import Session, select

from app.routers.tables import create_table
from app.services.profiling_engine import run_table_profiling
from core.services.profiling_engine import run_table_profiling

logging.basicConfig(
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"minio>=7.2.0",
"core",
"mcp>=1.2.0",
"temporalio>=1.6.0",

@coderabbitai coderabbitai Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider pinning temporalio to an exact/bounded version.

temporalio>=1.6.0 has no upper bound; an unvetted future major/minor bump could break the worker without warning. Other deps in this repo (e.g., sqlmodel==0.0.22) are pinned exactly.

🤖 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 `@backend/pyproject.toml` at line 28, The backend dependency list currently
allows temporalio to float with only a lower bound, which risks untested
upgrades breaking the worker. Update the dependency entry in pyproject.toml for
temporalio to use an exact pin or a bounded version range consistent with other
locked dependencies like sqlmodel, so future installs remain predictable.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yes he's right, change to fixed version

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🤖 web_search executed:

What is the latest release version of the temporalio Python SDK on PyPI?

Length of output: 2085


@yuvalkh, sounds good! For reference, the latest stable release is temporalio==1.30.0 on PyPI. You could pin it like this:

"temporalio==1.30.0",

Alternatively, if you'd like some flexibility for patch-level updates while still avoiding untested major/minor bumps, a bounded range like temporalio>=1.6.0,<2.0.0 would also work, consistent with how tightly other deps in this file are pinned (e.g., sqlmodel==0.0.22).

ᕦ(ò_óˇ)ᕤ

"python-core-utils[keycloak] @ git+ssh://git@github.com/matzpen-agency/python-core-utils.git@1.2.0#egg=python-core-utils",
]

Expand Down
42 changes: 42 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies = [
"trino>=0.328.0",
"pgvector>=0.2.0",
"psycopg2-binary==2.9.9",
"langchain",
"langchain-openai",
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin langchain/langchain-openai versions.

These are added with no version constraint whatsoever, risking unreproducible builds and unannounced breaking changes (both packages iterate quickly). Pin to a known-compatible range, consistent with other pinned deps like sqlmodel==0.0.22.

🤖 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 `@core/pyproject.toml` around lines 15 - 16, The dependency entries for
langchain and langchain-openai are unpinned, which can lead to non-reproducible
builds and unexpected breakage. Update the dependency definitions in
pyproject.toml to use a known-compatible version range or exact pins, matching
the project’s existing pinned dependency style like sqlmodel==0.0.22, so the
package resolver always installs the intended versions.

"python-core-utils[postgres,redis] @ git+ssh://git@github.com/matzpen-agency/python-core-utils.git@1.2.0",
]

Expand Down
6 changes: 6 additions & 0 deletions core/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ class CoreSettings(BaseSettings):

CATALOG_VALID_TTL: int = 300

PROFILER_MAX_CONCURRENT_QUERIES: int = 20
PROFILING_CHUNK_SIZE: int = 15
LLM_BASE_URL: str | None = None
LLM_MODEL: str | None = None
LLM_API_KEY: str | None = None

# Starburst Galaxy Configuration
USE_GALAXY: bool = False
GALAXY_HOST: str = ""
Expand Down
Loading