fix!: address managed databases by id only, never by name - #44
fix!: address managed databases by id only, never by name#44rohan-hotdata wants to merge 1 commit into
Conversation
Hotdata database names are display labels and are not unique. The framework's
resolve_managed_database tries the id first and then scans list_databases()
matching on name, so a label collision resolves to the wrong database and every
query, load and drop follows it there. Every database-addressing path in this
package went through that resolver, and hotdata_load_managed_table made it
reachable from an LLM — where a wrong target means a replacing load overwrites
another database's table.
resolve_database_by_id goes straight to GET /databases/{id} with no by-name
fallback, and returns an already-resolved ManagedDatabase untouched. The tool
factories resolve once at construction and carry the resolved record, so a bad
id fails while wiring the tools up rather than on the agent's first query, and
queries no longer pay a per-call resolution round-trip. query_scope rejects a
string scope reaching the query helpers, which would otherwise reach the
by-name fallback despite the annotations.
Mirrors hotdata-dlt-destination #59.
Breaking: database= becomes database_id= on make_hotdata_tools,
make_hotdata_search_tool and make_hotdata_describe_tables_tool; the
hotdata_load_managed_table tool's database argument becomes database_id;
load_managed_table takes database_id=; execute_sql_json, bm25_search_json and
describe_tables_json take a resolved ManagedDatabase.
Verified against a live workspace: a display label the framework does resolve
is now rejected, ids resolve and scope queries, and the demo runs end to end
both self-bootstrapping and with --database-id. An unscoped agent asked to load
into a database named only by its label called hotdata_list_managed_databases
and then passed the correct id in 5 of 5 runs.
| LoadManagedTableResult, | ||
| ManagedDatabase, | ||
| ) | ||
| from hotdata_framework.databases import api_error_message, managed_database_from_detail |
There was a problem hiding this comment.
nit: this is the only place the package reaches into a hotdata_framework submodule — every other import goes through the top-level namespace (from hotdata_framework import ...). api_error_message and managed_database_from_detail look like framework internals rather than public API, and pyproject.toml pins hotdata-framework>=0.9.0 with no upper bound, so a rename in either breaks import hotdata_langchain at module load rather than at call time.
Worth either an upper bound on the pin or a one-line note here that these two are borrowed from the framework's internals, so the coupling is visible when the pin is next bumped. (not blocking)
| target = ( | ||
| hl.resolve_database_by_id(client, args.database_id) | ||
| if args.database_id | ||
| else find_database_by_label(client, DATABASE_LABEL) | ||
| ) | ||
| if target is None: | ||
| print(f"No managed database labelled {DATABASE_LABEL!r} to delete") | ||
| else: | ||
| client.delete_managed_database(existing.id) | ||
| print(f"Deleted managed database {DATABASE!r} (id={existing.id})") | ||
| client.delete_managed_database(target) | ||
| print(f"Deleted managed database {target.id} (label={target.description!r})") |
There was a problem hiding this comment.
nit: the --cleanup path picked up two changes that no test and — going by the verification notes — no live run covers:
- With
--database-id,resolve_database_by_idraisesKeyErroron an unknown id, so thetarget is Nonebranch is unreachable on that path: the user gets a traceback instead of the tidy message. And because this block sits above thetry:/finally: client.close(), the client is never closed. The oldfind_databasereturnedNone, so cleanup always exited cleanly. Wrapping the resolve in atry/except KeyErrorthat prints and returns would restore that. delete_managed_database(target)now receives the resolved record where it previously gotexisting.id. Consistent with the rest of the change and presumably fine through the same resolverload_managed_tablegoes through, but a single--cleanuprun would confirm it.
Separately, No managed database labelled {DATABASE_LABEL!r} to delete names the label even when the user pinned an id, which reads oddly on that path. (not blocking)
Closes #38.
Why
Hotdata database names are display labels and are not unique.
hotdata-framework'sresolve_managed_database(name_or_id)triesget_database(id)first and then falls back to scanninglist_databases()matching ondb.name, so a label collision resolves to the wrong database — and every query, load and drop then follows it there.Every database-addressing path in this package went through that resolver, and
hotdata_load_managed_tablemade it reachable from an LLM. That tool replaces the table it loads into, so a wrong target means overwriting another database's data. This repo is a worse case thanhotdata-dlt-destinationwas, because the value can be model-generated rather than configured.Latent rather than live: the test workspace has 8 managed databases with no duplicate labels.
What
Mirrors hotdata-dlt-destination#59 — bind by id once, then address the resolved record.
resolve_database_by_idgoes straight toGET /databases/{id}viaDatabasesApi(client.api), the same move as dlt-destination's_bind_by_id. No by-name fallback exists on this path at all, rather than one guarded after the fact. An already-resolvedManagedDatabasepasses through untouched.execute_sql(database="<id>")was re-resolving on every call.query_scoperejects a string scope reaching the query helpers with aTypeErrornaming the fix. Worth having as a runtime guard because mypy is not in CI yet (CI: test the supported Python range, and run ruff + mypy #42), so annotations alone would not stop it.KeyErrornaminghotdata_list_managed_databasesas where ids come from.Breaking changes
make_hotdata_tools(database=...)database_id=(id, or aManagedDatabase)make_hotdata_search_tool(database=...)database_id=make_hotdata_describe_tables_tool(database=...)database_id=load_managed_table(database=...)database_id=hotdata_load_managed_tabletool argdatabasedatabase_idexecute_sql_json/bm25_search_json/describe_tables_jsondatabase=took a name or idManagedDatabaseTo keep scoping to the same database, pass its id —
client.list_managed_databases()reports one per database.Suggest releasing this as 0.3.0, matching how dlt-destination shipped its id-only change as a single minor bump. #23 is a stale
release/v0.3.0whose changelog text never merged; it should be closed and the branch deleted before re-preparing.Verification
Against a live workspace:
langchain_bm25_demoandf1_db— labels the framework does resolve — are now rejected, while their ids resolve and correctly scope queries.--database-id, andexamples/langchain_basic.pyruns clean.langchain_bm25_demo" — no id in the prompt — calledhotdata_list_managed_databasesand then passed the correctdatabase_idin 5 of 5 runs. The pointer lives in the load tool's own description, which names both tools that hand out ids.18 new tests (139 total), ruff and format clean, changelog check passes. mypy is unchanged at the 18 pre-existing errors in test files tracked by #42; package, demo and examples are clean.
Two things worth a reviewer's attention
A description change I made and then reverted. I first added "Call this first whenever you need a database id" to
hotdata_list_managed_databases. It fired on 3/5 demo runs where the tool set was already scoped and no id was needed, burning a turn on an irrelevant call. Removed — the pointer belongs in the tool that needs an id, not broadcast to every plan. Spurious calls went to 0/5.A correction to
demo/README.md. Chasing that, I found the checked-in "aggregate correct 5 of 5" does not replicate; I measured 3/5. Before attributing it to this change I ran five runs on mergedmainin a worktree, scored against a ground-truth SQL query —mainis also 3/5. So this PR did not regress it and the previous figure was a lucky sample. The README now reports the measured figure and the two failure modes. Search and describe are called every run on both, which is the part these tools are responsible for.One deliberate exception to id-only addressing:
demo/bm25_search_demo.pykeeps a single by-label lookup so re-runs work without pinning an id, clearly marked as demo bootstrap. dlt-destination dropped its equivalent, but that is a destination; this is a script whose job is idempotent re-runnability. Pass--database-idto skip it.