-
Notifications
You must be signed in to change notification settings - Fork 0
add temporal service #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ben/category-enrichment
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ( | ||
|
|
@@ -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, | ||
|
|
@@ -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)): | ||
| 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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| """ | ||
| 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) | ||
|
|
@@ -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( | ||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -SRepository: StavPonte11/text2sql-onboarding Length of output: 4502 🌐 Web query:
💡 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
🤖 Prompt for AI Agents |
||
| return profile | ||
|
|
||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -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), | ||
| ): | ||
| """ | ||
|
|
@@ -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): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -SRepository: 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.tsxRepository: 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 -SRepository: 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 -SRepository: 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 -SRepository: StavPonte11/text2sql-onboarding Length of output: 4798 Surface cancellation failures instead of unconditionally marking the profile failed. 🤖 Prompt for AI Agents |
||
|
|
||
| # ── GET /tables/{id}/profile/columns ────────────────────────────────────────── | ||
| @router.get( | ||
| "/tables/{table_id}/profile/columns", response_model=list[ColumnProfileRead] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ dependencies = [ | |
| "minio>=7.2.0", | ||
| "core", | ||
| "mcp>=1.2.0", | ||
| "temporalio>=1.6.0", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Consider pinning
🤖 Prompt for AI Agents
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes he's right, change to fixed version There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🤖 web_search executed:
Length of output: 2085
"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 ᕦ(ò_óˇ)ᕤ |
||
| "python-core-utils[keycloak] @ git+ssh://git@github.com/matzpen-agency/python-core-utils.git@1.2.0#egg=python-core-utils", | ||
| ] | ||
|
|
||
|
|
||
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 |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Pin 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 🤖 Prompt for AI Agents |
||
| "python-core-utils[postgres,redis] @ git+ssh://git@github.com/matzpen-agency/python-core-utils.git@1.2.0", | ||
| ] | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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):