Skip to content
Merged
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
29 changes: 26 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`description` and `default_connection_id`, so a caller holding a resolved record could not ask
what was attached to it. This reads the fields that record drops.

These are Python helpers and deliberately not tools. Whether provisioning of this kind should be
agent-callable is the open question in
[#61](https://github.com/hotdata-dev/hotdata-langchain/issues/61), and nothing here settles it.
- **`hl.database_expiry` and `hl.database_expiries`.** A lifetime is *written* as a string,
either an RFC 3339 timestamp or a relative window such as `"24h"`, and the server resolves it
to an instant. So the resolved time is only knowable by reading it back, and nothing in this
package could: `ManagedDatabase` carries no `expires_at`. A caller could set a TTL and then not
learn which second it landed on, or whether a database still had one at all.

`database_expiries` returns the workspace keyed by database id, at one request per page of the
listing rather than one per database, because the listing response already carries the field. A
database with no TTL maps to `None`, so "lives forever" stays distinguishable from "not in this
workspace". It reads the listing endpoint directly, since `client.list_managed_databases()`
drops `expires_at`, reads every database individually, and silently omits any whose detail read
fails.

The listing is paginated and the cursor is followed, so a workspace larger than one page is not
reported as complete after one read. Two guards stop paging early and log a warning rather than
raising: a workspace past 10,000 records, and a listing that repeats a cursor it already gave.
A caller that must know the read was complete has to watch the log, not the return value.

Reaping happens from the TTL or from an explicit cleanup step, so an unexplained timestamp in
a tool result would be surface a model can only misuse.

Everything above is a Python helper, and none of it reaches a tool. For the attach pair, whether
provisioning should be agent-callable at all is the open question in
[#61](https://github.com/hotdata-dev/hotdata-langchain/issues/61), which this release does not
settle. For the read-back helpers it is simpler: a model has no decision to make with either
value.

## [0.15.0] - 2026-09-01

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,29 @@ These are Python helpers, not tools. Whether an agent should be able to attach a
is [#61](https://github.com/hotdata-dev/hotdata-langchain/issues/61)'s open question, and this
does not answer it.

## Reading back when a database expires

`expires_at` is written as a string, either an RFC 3339 timestamp or a relative window like
`"24h"`, and the server resolves it to an instant. A caller that passed `"24h"` therefore does
not know which second it lands on, and `ManagedDatabase` carries no `expires_at` to consult:

```python
print(hl.database_expiry(client, db)) # datetime, or None when it has no TTL
print(hl.database_expiries(client)) # {database_id: datetime | None} for the workspace
```

`database_expiries` costs one request per page of the database listing, not one per database,
because the listing already carries the field. A database with no TTL maps to `None`, which keeps
"lives forever" distinguishable from "not in this workspace".

The cursor is followed across pages, so one read does not pass off a subset as the whole
workspace. Two guards stop it early and log a warning instead of raising: more than 10,000
records, or a listing that repeats a cursor. If you need certainty that the read was complete,
watch the log rather than the returned mapping.

Neither is on a tool. Reaping runs from the TTL or from your own cleanup step, so a model has no
decision to make with the value.

## Controlling result size

Limit how many rows are returned to the LLM. Useful for keeping responses within context limits (default: 100):
Expand Down
4 changes: 4 additions & 0 deletions hotdata_langchain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
attach_catalog,
create_managed_database,
database_attachments,
database_expiries,
database_expiry,
detach_catalog,
list_managed_databases_json,
load_managed_table,
Expand Down Expand Up @@ -137,6 +139,8 @@
"capabilities_by_column",
"create_managed_database",
"database_attachments",
"database_expiries",
"database_expiry",
"describe_tables_json",
"detach_catalog",
"engine_error_message",
Expand Down
82 changes: 81 additions & 1 deletion hotdata_langchain/databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import logging
import socket
import tempfile
from collections.abc import Sequence
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlsplit
Expand Down Expand Up @@ -68,6 +69,11 @@ class CatalogAttachment:
MAX_DOWNLOAD_BYTES = 1024**3
DOWNLOAD_CHUNK_BYTES = 1024 * 256

#: A stop on paging database listings, counted in records read rather than in distinct
#: ids: a server repeating one page never grows the set of ids, so a set size would not
#: terminate. A repeated cursor is caught separately.
_MAX_DATABASES_SCANNED = 10_000


def resolve_database_by_id(
client: HotdataClient,
Expand Down Expand Up @@ -329,6 +335,80 @@ def detach_catalog(
)


def database_expiry(
client: HotdataClient,
database_id: str | ManagedDatabase,
) -> datetime | None:
"""Return when ``database_id`` is due to be reaped, or ``None`` if it has no TTL.

A lifetime is *written* as a string, either an RFC 3339 timestamp or a relative window
such as ``"24h"``, and the server resolves it to an instant. So the resolved instant is
only ever knowable by reading it back: a caller that passed ``"24h"`` does not know
which second it lands on, and ``ManagedDatabase`` carries no ``expires_at`` to consult.

Raises ``KeyError`` when the workspace has no database with that id.
"""
return _database_detail(client, database_id).expires_at


def _database_summaries(client: HotdataClient) -> Iterator[Any]:
"""Yield database summaries, following the listing cursor across pages.

``list_databases`` is paginated, so reading one page reports a subset as if it were the
whole workspace. Two guards end paging before the listing does, each logging a warning
and returning what it has: a workspace past ``_MAX_DATABASES_SCANNED`` records, and a
cursor arriving a second time.
"""
api = DatabasesApi(client.api)
cursor: str | None = None
scanned = 0
used: set[str] = set()
while True:
try:
listing = api.list_databases(cursor=cursor) if cursor else api.list_databases()
except ApiException as e:
raise RuntimeError(api_error_message(e)) from e
for summary in listing.databases or ():
yield summary
scanned += 1
cursor = listing.next_cursor
if not cursor or not listing.databases:
return
if cursor in used:
logger.warning(
"database listing returned cursor %r a second time; expiries are partial",
cursor,
)
return
used.add(cursor)
if scanned > _MAX_DATABASES_SCANNED:
logger.warning(
"stopped paging database listings after %d records; expiries are partial",
scanned,
)
return


def database_expiries(client: HotdataClient) -> dict[str, datetime | None]:
"""Return every instant database's expiry in the workspace, keyed by database id.

One call per page of the listing, rather than one call per database: the listing
response already carries ``expires_at``, so nothing here needs a per-database read.
A database with no TTL maps to ``None``, so a caller can tell "lives forever" from
"not in this workspace", which a missing key would not distinguish.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: the public docstring promises the whole workspace and the code can return a subset (not blocking). _MAX_DATABASES_SCANNED and the repeated-cursor stop both end paging with a logger.warning only, so a caller gets a truncated mapping and no programmatic signal. Distinguishing a complete read from a subset is the stated reason this function exists. Document both stops here, so a caller with more than 10,000 databases knows the log is the only notice.


This reads the listing endpoint directly rather than going through
``client.list_managed_databases()``, which drops ``expires_at``, fetches every database
individually, and silently omits any whose detail read fails.

**The result can be a subset, and the only notice is a log line.** Paging stops early
on two guards: a workspace past 10,000 records, and a listing that returns a cursor it
already gave. Each logs a warning and returns what it has, so a caller that must know
the read was complete has to watch the log rather than the return value.
"""
return {str(one.id): one.expires_at for one in _database_summaries(client)}


def list_managed_databases_json(client: HotdataClient) -> str:
"""List this workspace's instant databases as JSON, each with its ``id`` and ``name``.

Expand Down
Loading
Loading