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
9 changes: 8 additions & 1 deletion nodes/src/nodes/db_hotdata/IGlobal.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
from .hotdata_client import HotdataClient


#: Smallest async_after_ms the API accepts. Anything lower is answered with
#: 400 "async_after_ms must be at least 1000", which failed every single query
#: for a pipeline configured below it. services.json advertises the same floor.
ASYNC_AFTER_MS_MIN = 1000


def _int_or(value: Any, default: int, *, lo: int, hi: int) -> int:
try:
n = int(value)
Expand Down Expand Up @@ -94,6 +100,7 @@ class IGlobal(IGlobalBase):
async_after_ms: int = 5000

def beginGlobal(self) -> None:
"""Read the node config and build the REST client; the database itself is created lazily."""
if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG:
return

Expand Down Expand Up @@ -128,7 +135,7 @@ def beginGlobal(self) -> None:
self.allow_execute = _bool_or(cfg.get('allow_execute'), False)
self.allow_destructive_load = _bool_or(cfg.get('allow_destructive_load'), False)
self.job_timeout_secs = _int_or(cfg.get('job_timeout_secs'), 300, lo=10, hi=3600)
self.async_after_ms = _int_or(cfg.get('async_after_ms'), 5000, lo=0, hi=60000)
self.async_after_ms = _int_or(cfg.get('async_after_ms'), 5000, lo=ASYNC_AFTER_MS_MIN, hi=60000)

self.client = HotdataClient(
apikey=self.apikey,
Expand Down
194 changes: 181 additions & 13 deletions nodes/src/nodes/db_hotdata/IInstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,13 @@
_POLL_MAX_S = 5.0

_TERMINAL_OK = frozenset({'succeeded', 'success', 'completed', 'complete', 'ready', 'finished'})
_TERMINAL_BAD = frozenset({'failed', 'error', 'cancelled', 'canceled'})
_TERMINAL_BAD = frozenset({'failed', 'error', 'interrupted', 'cancelled', 'canceled'})
#: Terminal, but not the statement's fault, so raised as a plain RuntimeError and
#: never as SqlStatementError. Only `interrupted` is a documented status of a
#: query run (https://www.hotdata.dev/openapi.yaml: "terminal, and safe to
#: retry"); `cancelled` / `canceled` were never seen from this endpoint and are
#: kept from the original set as a guard.
_ENDED_NOT_BY_THE_SQL = frozenset({'interrupted', 'cancelled', 'canceled'})

#: Async job states from the loads/indexes 202 envelope.
_JOB_PENDING = frozenset({'pending', 'running'})
Expand Down Expand Up @@ -194,6 +200,64 @@ def _to_ndjson(rows: List[Any]) -> bytes:
return ('\n'.join(lines) + '\n').encode('utf-8')


class SqlStatementError(RuntimeError):
"""The server ran the statement and the statement failed.

Distinct from a transport or account failure: this one names something in the
SQL, so regenerating is worth a turn. Carried as its own type because a
deferred run reports its failure in the poll body, with no HTTP status to
classify on.
"""


def _run_failure_text(run: Dict[str, Any], fallback: str) -> str:
"""Why a query run failed, in the server's words.

The run body reports it as ``error_message`` ("query execution failed: Arrow
error: Divide by zero error"). This text is what gets fed back to the model
for its corrective attempt, so falling through to the bare status would send
it "failed" and leave it rewriting blind. ``error`` and ``message`` are read
too - ``error`` as either a string or the ``{"message": ...}`` object the
query endpoint uses - so a body shaped like the other endpoints still works.
"""
for key in ('error_message', 'error', 'message'):
value = run.get(key)
if isinstance(value, dict):
value = value.get('message')
if value:
return str(value)
return fallback


def _is_sql_fixable(error: Exception) -> bool:
"""Could a *different statement* plausibly succeed where this one failed?

Only worth another generation turn when the server rejected the SQL itself -
a missing table, a parse error, an unknown function all come back 400. A
failure that describes the run rather than the statement (401 invalid_api_key,
404 workspace_not_found or database not found, an exhausted 429 budget, a
connection that never landed) is identical on every retry, so re-asking the
model only spends another LLM call and another round trip before reporting
the same error with the cause buried behind "could not answer after N
attempts".

The status alone is too coarse: ``_run_sql`` drives four endpoints, and a
bad ttl on create_database, a malformed limit on get_result or a missing
scoping header all answer 400 without a single thing being wrong with the
SQL. The server marks a real statement failure by answering with a
``query_run_id`` - it only mints one once it has created a run and executed
the statement - so that, not the bare 400, is the test.

Defaults to *not* fixable: a failure this code cannot classify is far more
likely to be environmental than to be a statement the model can rewrite.
"""
if isinstance(error, SqlStatementError):
return True
if getattr(error, 'status_code', None) != 400:
return False
return bool(getattr(error, 'query_run_id', ''))


def _is_missing_column(error: Exception) -> bool:
"""Is this the server refusing a write that omits a column the table has?

Expand Down Expand Up @@ -309,8 +373,90 @@ def _rows_from_payload(payload: Any) -> List[Dict[str, Any]]:
return []


def _free_name(base: str, used: set[str]) -> str:

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.

nit - this belongs one level down, where the same bug is still live.

_free_name and _name_columns fix something real: dict(zip(columns, row)) silently drops a value when a join returns the same column name twice, and your SELECT * self-join note is the right reproduction.

That naive form is still in shared code other nodes run through:

  • packages/ai/src/ai/common/database/db_instance_base.py:926 - return [dict(zip(column_names, row)) for row in rows]
  • packages/ai/src/ai/common/database/tx_registry.py:165 - {'rows': [dict(zip(cols, row)) for row in rows], ...}

SELECT * FROM sales a JOIN sales b loses a column there for the same reason it did here.

Not work for this PR. But .claude/pr-review-rules.md puts shared helpers in ai.common.utils, and promoting _rows_as_objects there (or opening a follow-up) would let the SQL nodes inherit the fix, instead of waiting for the same bug report to arrive from a different direction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and agreed it is not work for this PR — no change here.

There is a third site beyond the two you listed: packages/ai/src/ai/common/database/db_instance_base.py:1093 (rows = [dict(zip(headers, row)) for row in items]). I will open a follow-up to promote the naming helper into ai.common and point all three at it, so the SQL nodes inherit the join fix.

One question: .claude/pr-review-rules.md is not in the repo on develop — is it on a branch, or local to your checkout? I would like to read it before the follow-up.

"""``base``, suffixed until it is a key nothing has claimed, then reserved.

The generated name has to be checked against the names already taken, not
just against the base: for columns ``['city', 'city', 'city_1']`` the second
``city`` would otherwise become ``city_1`` and collide with the real third
column.
"""
name = base
suffix = 1
while name in used:
name = f'{base}_{suffix}'
suffix += 1
used.add(name)
return name


def _name_columns(columns: List[Any]) -> List[str]:
"""Column names for a result, made unique and non-empty.

A join comes back with the same name twice: ``SELECT a.city, b.city`` answers
``['city', 'city']`` and ``SELECT *`` over a self-join answers ``['city',
'units', 'city', 'units']`` (the planner rejects a bare ``SELECT city, city``,
so joins are how this is reached). Zipping that into a dict would keep only
the last value and silently drop a column.
"""
used: set[str] = set()
named: List[str] = []
for index, raw in enumerate(columns):
name = str(raw).strip() if raw is not None else ''
if not name:
name = f'column_{index + 1}'
named.append(_free_name(name, used))
return named


def _rows_as_objects(rows: Any, columns: Any) -> List[Any]:
"""Pair positional result rows with the column names returned beside them.

``/v1/query`` and ``/v1/results`` both answer with ``columns`` (the names) and
``rows`` (a list of value *lists*), never with objects. Everything downstream
is written against objects - the Markdown table, the information_schema
reshaping, the rows handed to the agent - so the names are attached here, at
the one boundary where both halves are in hand. Without this the rows keep
their positions and nothing can name a field: the table renderer falls
through to ``str(row)`` and emits Python list reprs.

Rows that are already objects are passed through, so a server that starts
returning objects, and the tests that mock them, keep working. A row
*narrower* than its header yields an object without the trailing keys rather
than inventing nulls for values the server never sent.
"""
if not isinstance(rows, list) or not rows:
return rows if isinstance(rows, list) else []
if not isinstance(columns, list) or not columns:
return rows
names = _name_columns(columns)
out: List[Any] = []
for row in rows:
if isinstance(row, dict) or not isinstance(row, (list, tuple)):
out.append(row)
continue
item: Dict[str, Any] = {name: row[i] for i, name in enumerate(names) if i < len(row)}
# A row wider than its header keeps the tail rather than dropping it:
# losing a value silently is worse than naming it by position. The
# positional key goes through _free_name too, because `column_3` is
# itself a legal column name and would otherwise be overwritten.
if len(row) > len(names):
used = set(item)
for i in range(len(names), len(row)):
item[_free_name(f'column_{i + 1}', used)] = row[i]
out.append(item)
return out


def _cell(value: Any) -> str:
"""Render one table cell: pipes and newlines both break the row otherwise."""
"""Render one table cell: pipes and newlines both break the row otherwise.

SQL NULL arrives as ``None`` and renders as an empty cell. ``str(None)`` would
put the Python word "None" in front of a reader who asked a question about
their data - the same leak as the list reprs, one value at a time.
"""
if value is None:
return ''
return str(value).replace('|', '\\|').replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')


Expand All @@ -328,7 +474,9 @@ def _rows_to_markdown(rows: List[Any], limit: int = 100) -> str:
if key not in columns:
columns.append(key)

header = '| ' + ' | '.join(columns) + ' |'
# Header cells are escaped like body cells: `SELECT total AS "a|b"` is a
# legal alias, and an unescaped pipe in the header splits the column in two.
header = '| ' + ' | '.join(_cell(c) for c in columns) + ' |'
divider = '| ' + ' | '.join('---' for _ in columns) + ' |'
body = ['| ' + ' | '.join(_cell(row.get(c, '')) for c in columns) + ' |' for row in shown]
table = '\n'.join([header, divider] + body)
Expand Down Expand Up @@ -381,27 +529,34 @@ def _run_sql(self, sql: str, limit: int) -> Dict[str, Any]:
# 25,000 - so the id is only offered when the caller has seen all of it.
run_id = response.get('query_run_id') or response.get('id')
if response.get('rows') is not None and not response.get('truncated'):
rows = response.get('rows') or []
rows = _rows_as_objects(response.get('rows') or [], response.get('columns'))
result = {'rows': rows[:limit], 'row_count': len(rows[:limit]), 'sql': sql}
if response.get('result_id') and len(rows) <= limit:
result['result_id'] = response['result_id']
return result

if run_id and response.get('result_id') is None:
response = self._await_run(run_id)
# A truncated response always carries a result_id, but per QueryResponse in
# https://www.hotdata.dev/openapi.yaml that id "is issued while the save is
# still in flight, so on its own it does not mean the result can be
# retrieved": a read against it answers 202 with no rows, which came back
# from here as an empty result. The run is the readiness signal, so wait
# on it whenever the server deferred or truncated.
if run_id and (response.get('result_id') is None or response.get('truncated')):
response = self._await_run(run_id, database_id)

result_id = response.get('result_id')
if result_id:
payload = glb.client.get_result(result_id, offset=0, limit=limit)
rows = payload.get('rows') or payload.get('data') or []
payload = glb.client.get_result(result_id, database_id=database_id, offset=0, limit=limit)

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.

MUST fix - a result that is still being saved comes back as an empty answer.

When /v1/query answers truncated: true it always carries a result_id, so the branch above (if run_id and response.get('result_id') is None) does not fire and this line reads the result straight away.

The published contract says that id is not ready yet. From QueryResponse in https://www.hotdata.dev/openapi.yaml:

This response body's result_id is issued while the save is still in flight, so on its own it does not mean the result can be retrieved.

and GET /v1/results/{id} documents this row in its own status table:

pending/processing -> 202 application/json {status, result_id} + Retry-After

HotdataClient._request only raises on status >= 400, so a 202 is returned as an ordinary body. It has no rows and no columns, so the next line yields [], _rows_as_objects passes it through, and _run_sql returns {'rows': [], 'row_count': 0, 'sql': sql}. The agent then reports that the query matched nothing - for a result large enough to have been truncated. No exception, no log line, nothing red.

The spec also names the fix:

Do not poll GET /results/{id} to wait for readiness. [...] A result_id on the run names a saved result that is ready to retrieve [...] the result is saved and marked ready before the run is marked succeeded, never after.

_await_run already is that loop. Waiting for the run whenever the server deferred or truncated is one condition:

        if run_id and (response.get('result_id') is None or response.get('truncated')):
            response = self._await_run(run_id, database_id)

A defensive check inside get_result is worth having either way: a body carrying a status of pending/processing and no rows means "not ready", not "empty".

Found by reading the spec against this branch. The trigger is a timing race, so one live run proves nothing in either direction - and python -m pytest nodes/test/test_db_hotdata.py has no case for a 202 from get_result today.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c628275 — and reproduced live, not just taken from the spec.

What it was: a truncated response always carries a result_id, so the result_id is None test never waited, and that id is issued while the save is still in flight. Reading it early answers 202 with no rows, which _run_sql returned as an empty result.

8M-row query against api.hotdata.dev, 2026-09-19:

POST /v1/query                   -> 200  truncated: true, result_id issued
GET  /v1/results/{id}  (at once) -> 202  {"result_id": "...", "status": "processing"}   no rows, no columns
GET  /v1/query-runs/{id}         -> running (no result_id) -> succeeded (result_id, row_count=8000000)
GET  /v1/results/{id}  (after)   -> 200  rows

In three further runs through the real _run_sql the first poll was running every time, so for a result that size it is not a rare race — the old code would have answered empty essentially always. You were right that one live run proves nothing: my earlier "truncated path verified live" run used 200k rows and simply won.

How it is addressed:

  • _run_sql uses your condition as written — it waits on the run whenever the server deferred or truncated. response becomes the run body, so the id used for the read is the run's, as the spec asks ("Read result_id off the query run rather than off this body").
  • The defensive check is in get_result: a pending / processing body with no rows raises HotdataError (status_code=202) — "not ready", never "empty". Raised rather than polled, per "Do not poll GET /results/{id} to wait for readiness".
  • Tests, since there was no 202 case: a 202 from get_result in both states; the read follows the run and uses the run's id; and a guard that a genuinely empty ready result still reads as empty. The first two fail on aed1dce.

raw = payload.get('rows') or payload.get('data') or []
rows = _rows_as_objects(raw, payload.get('columns'))
result = {'rows': rows[:limit], 'row_count': len(rows[:limit]), 'sql': sql}
# get_result is explicitly a window; a full page back means there may
# be more behind it, and the id would then name more than was seen.
if len(rows) < limit:
result['result_id'] = result_id
return result

rows = response.get('rows') or []
rows = _rows_as_objects(response.get('rows') or [], response.get('columns'))
return {'rows': rows[:limit], 'row_count': len(rows[:limit]), 'sql': sql}

def _schema_via_sql(self) -> List[Dict[str, Any]]:
Expand Down Expand Up @@ -457,17 +612,23 @@ def _table_columns(self, schema: str, table: str) -> List[str]:
names.append(str(value))
return names

def _await_run(self, run_id: str) -> Dict[str, Any]:
def _await_run(self, run_id: str, database_id: str = '') -> Dict[str, Any]:
"""Poll a query run to a terminal state under a monotonic deadline."""
glb = self.IGlobal
deadline = time.monotonic() + glb.job_timeout_secs
delay = _POLL_BASE_S
while True:
run = glb.client.get_query_run(run_id)
run = glb.client.get_query_run(run_id, database_id=database_id)
status = str(run.get('status') or '').lower()
if status in _TERMINAL_BAD:
message = run.get('error') or run.get('message') or status
raise RuntimeError(f'db_hotdata: query failed: {message}')
message = _run_failure_text(run, status)
if status in _ENDED_NOT_BY_THE_SQL:
# Not the statement's fault, so not SqlStatementError: a
# rewritten query cannot un-interrupt or un-cancel a run, and
# classifying it as fixable would spend an LLM turn finding
# that out.
raise RuntimeError(f'db_hotdata: query run ended as {status!r}: {message}')
raise SqlStatementError(f'db_hotdata: query failed: {message}')
if status in _TERMINAL_OK or run.get('result_id'):
return run
if time.monotonic() + delay > deadline:
Expand Down Expand Up @@ -888,6 +1049,7 @@ def get_sql(self, args: Any) -> Dict[str, Any]:
),
)
def get_data(self, args: Any) -> Dict[str, Any]:
"""Answer a question: generate SQL, run it, and retry a rejected statement with its error fed back."""
args = normalize_tool_input(args, tool_name='get_data')
question_text = str(args.get('question') or '').strip()
if not question_text:
Expand All @@ -913,6 +1075,12 @@ def get_data(self, args: Any) -> Dict[str, Any]:
result['attempts'] = attempt
return result
except Exception as e:
if not _is_sql_fixable(e):
# Raised as-is: the original carries the status and the
# server's own wording, which "could not answer after 3
# attempts" would bury behind two pointless LLM calls.
debug(f'db_hotdata: attempt {attempt} hit a failure no rewrite can fix: {e}')
raise
previous_sql, last_error = cleaned, str(e)
debug(f'db_hotdata: attempt {attempt} failed: {last_error}')

Expand Down
3 changes: 2 additions & 1 deletion nodes/src/nodes/db_hotdata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ An agent flow: `load_data` the rows, `build_index` on the text column, then `get
| Function | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------- |
| `load_data` | Load rows, or a previous query result by `result_id`, into a table. Creates the table if needed |
| `get_data` | Answer a plain-language question. The bound LLM writes the SQL; failures are retried with the error fed back |
| `get_data` | Answer a plain-language question. The bound LLM writes the SQL; only rejected SQL is retried, with its error |
| `get_sql` | Generate the SQL for a question without running it |
| `execute` | Run one raw read-only SQL statement. Gated by `allow_execute` |
| `get_schema` | Live tables and columns from `information_schema` |
Expand Down Expand Up @@ -125,6 +125,7 @@ Hotdata runs **Apache DataFusion 54 behind the PostgreSQL parser dialect**. Trea
| Symptom | Cause |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apikey is required` / `workspace_id is required` | Neither the config field nor the env var is set |
| `HTTP 401` / `HTTP 404` from `get_data` | Wrong API key, workspace or database ID. Raised at once: only a rejected SQL statement is retried |
| `raw SQL execution is disabled` | Turn on `allow_execute`, or use `get_data` instead |
| `only one statement per call` | Hotdata rejects semicolon-separated batches |
| Unknown function errors | A Postgres-only function that DataFusion lacks — check `dialect` |
Expand Down
Loading
Loading