Skip to content

fix: collision-safe, resolve-once managed-database addressing (#39) - #58

Merged
rohan-hotdata merged 1 commit into
mainfrom
fix/39-collision-safe-resolution
Jul 23, 2026
Merged

fix: collision-safe, resolve-once managed-database addressing (#39)#58
rohan-hotdata merged 1 commit into
mainfrom
fix/39-collision-safe-resolution

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Contributor

Fixes #39.

Problem

The destination is name-keyed: every managed-database operation independently resolves config.database_name → id via hotdata-framework's resolve_managed_database, which falls back to a list_databases() scan and takes the first name == match. Hotdata database names are not unique, so on a collision the destination could silently read from, write to, or drop the wrong database — with no error. The scan also repeats on every op (ensure, per-table add/load, state reads, has_dataset, drop), each independently vulnerable.

Changes

  • Collision-safe resolution (HotdataClient._collision_safe_resolve): lists the workspace's databases, and raises HotdataTerminalError (naming the ambiguous ids) when a name matches more than one, instead of silently picking one. An id matches exactly and is unambiguous.
  • Resolve-once, id-addressed (bind_run_cache + _resolve): the name is resolved to its record once per run, cached on the shared HotdataClientConfiguration instance (which the job client, its load jobs, and the read client all share), and every subsequent op — ensure/load/add/list/fetch/execute_sql/drop — addresses the database by id. drop clears the cache.

The cache holds the full ManagedDatabase (id + default_connection_id), which also seats the #55 follow-up: once hotdata-framework 0.9.0 (the ManagedDatabase passthrough, hotdata-dev/sdk-python-framework#52) is pinned, the destination catches the 403→create and passes the cached object (instead of the id) so a create-scoped key skips the read probe.

Scope

Cost note

Collision detection uses the framework's list_managed_databases() (1 list + N gets, since the list summary omits default_connection_id). With the per-run cache this runs once per run, then every later op is cache-served. A lighter summaries-only list on the framework/API side would drop it to a single call — tracked as an upstream follow-up.

Testing

  • 116 tests pass (rewrote tests/test_client.py to the id-scoped model; added ambiguous-name-raises, resolve-once/cache-reuse, resolve-by-id, drop-clears-cache, and id-addressing on load/ensure/fetch/execute). e2e InMemoryBackend and test_job_client fakes updated to the real contract (list_managed_databases / bind_run_cache).
  • ruff clean on changed/new files.

@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner July 23, 2026 14:01
@rohan-hotdata
rohan-hotdata requested review from zfarrell and removed request for a team July 23, 2026 14:01
Comment on lines +179 to +186
def table_is_synced(self, database: str, table: str, *, schema: str) -> bool:
db = self._resolve(database)
for managed_table in self._request_with_retry(
lambda: self._runtime.list_managed_tables(db.id, schema=schema)
):
if managed_table.table == table:
return managed_table.synced
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: table_is_synced and _table_is_synced_for (below) are the same loop — resolve + iterate list_managed_tables for a matching .synced. table_is_synced could resolve then delegate to _table_is_synced_for so the scan logic lives in one place. (not blocking)

claude[bot]
claude Bot previously approved these changes Jul 23, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Collision-safe resolution and the resolve-once/id-addressed rework look correct and are well-covered by the rewritten tests (ambiguity raises, resolve-once cache reuse, id-addressing on ensure/load/fetch/execute, drop clears cache). One non-blocking super-nit left inline.

Hotdata database names are not unique, and the destination resolved the
name on every operation via hotdata-framework's first-match list scan, so a
name collision could silently read from, write to, or drop the wrong
database.

Resolution now lists the workspace's databases, raises a clear error when a
name matches more than one, resolves the name to its record once per run
(cached on the shared config), and addresses every subsequent operation
(load/add/list/query/drop) by id. The cache holds the full ManagedDatabase,
which also seats the follow-up that lets a create-scoped key skip the read
probe (#55, pending the hotdata-framework 0.9.0 passthrough).
Comment on lines +179 to +186
def table_is_synced(self, database: str, table: str, *, schema: str) -> bool:
db = self._resolve(database)
for managed_table in self._request_with_retry(
lambda: self._runtime.list_managed_tables(db.id, schema=schema)
):
if managed_table.table == table:
return managed_table.synced
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: this public table_is_synced isn't called anywhere — the read path uses fetch_table, which goes through the private _table_is_synced_for. So it's dead code and duplicates the same scan. Simplest resolution (which also closes the earlier duplication note) is to just delete it. (not blocking)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Collision-safe resolution and resolve-once caching look correct: ambiguity raises before anything is cached, create/drop keep the cache consistent, and every op addresses the DB by id. One non-blocking super nit inline.

@rohan-hotdata
rohan-hotdata merged commit 0f61c5a into main Jul 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden managed-database resolution: name lookups are collision-prone (id-only follow-up)

1 participant