Skip to content

feat: read back when an instant database is due to be reaped - #98

Merged
rohan-hotdata merged 3 commits into
mainfrom
feat/database-expiry-readback
Sep 3, 2026
Merged

feat: read back when an instant database is due to be reaped#98
rohan-hotdata merged 3 commits into
mainfrom
feat/database-expiry-readback

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Contributor

The package can set a database lifetime and cannot read one back. This adds
hl.database_expiry and hl.database_expiries.

Follows #97, which is already merged. Both ship in the same release.

The gap

expires_at is written as a string, either an RFC 3339 timestamp or a relative window such
as "24h". It comes back as a datetime:

Model Field type
CreateDatabaseRequest.expires_at str | None
DatabaseDetailResponse.expires_at datetime | None
DatabaseSummary.expires_at datetime | None
ManagedDatabase absent

The server resolves the relative window, 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 a caller holding
a resolved ManagedDatabase cannot ask whether the database has a TTL at all.

That matters because a TTL is the whole disposal story for a short-lived database. The package
sets the thing and then cannot observe it.

Two functions

hl.database_expiry(client, db)   # datetime | None, for one database
hl.database_expiries(client)     # {database_id: datetime | None}, for the workspace

database_expiry reuses _database_detail, the same route database_attachments takes.

database_expiries costs one request per page of the listing, not one per database, because
DatabaseSummary already carries the field. A database with no TTL maps to None rather than
being absent from the mapping, so "lives forever" stays distinguishable from "not in this
workspace".

Why it reads the listing endpoint directly

client.list_managed_databases() is the obvious route and is wrong for this:

def list_managed_databases(self) -> list[ManagedDatabase]:
    listing = self._databases_api().list_databases()
    result: list[ManagedDatabase] = []
    for summary in listing.databases:
        try:
            detail = self._databases_api().get_database(summary.id)
            result.append(managed_database_from_detail(detail))
        except ApiException:
            pass
    return result

Three problems: it returns ManagedDatabase and so drops expires_at; it issues a
get_database per database; and it swallows ApiException per database, so one whose detail read
fails is omitted with no error. It also reads a single page.

That method is unchanged here. Its defects are noted on
#36, which already tracks framework
client gaps. Worth knowing that hotdata_list_managed_databases sits on that path.

Pagination

list_databases takes limit, cursor and search, so a single-page read would report a subset
of the workspace as though it were complete. database_expiries follows the cursor. It also stops
on an empty page and caps at 10,000 records, so a server that keeps returning a cursor cannot spin
the loop. Three tests cover the paging, including the cursor value sent on the second request.

Not on a tool

Reaping runs from the TTL or from an explicit cleanup step, so a model has no decision to make
with the value, and an unexplained timestamp in a tool result is surface it can only misuse. This
follows the line #91 drew for partition_by: the runtime supplies the fact, the model chooses
only when to act.

The asymmetry also favours waiting. Adding the field to hotdata_list_managed_databases later
stays additive. Removing it later would change what every model sees.

Verification

Gate Result
Suite 634 passed (622 before, +12)
mypy clean, 27 files
ruff check + format clean
--resolution lowest-direct 634 passed
Documented examples every snippet in both new README sections executed

Docs

Not in scope

No live run. Like #97, the endpoints and fields are verified against the SDK and the declared
floor, but this code path has not been exercised against a real workspace. TTL reaping itself
remains unobserved — this makes it observable, it does not confirm it happens.

A lifetime is written as a string, an RFC 3339 timestamp or a relative window
such as "24h", and the server resolves it to an instant. Nothing in this package
could read that back: ManagedDatabase carries no expires_at, so a caller could
set a TTL and never learn which second it landed on.

database_expiry reads one database. database_expiries returns the whole
workspace keyed by id, at one request per page of the listing rather than one
per database, because the listing response already carries the field.

The listing endpoint is read directly rather than through
client.list_managed_databases(), which drops expires_at, reads every database
individually, and swallows ApiException per database so a failed detail read
silently omits it. The listing is paginated, so the cursor is followed and a
workspace larger than one page does not report a subset as if it were complete.

Neither reaches a tool. Reaping runs from the TTL or from an explicit cleanup
step, so a model has no decision to make with the value.
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner September 3, 2026 13:53
@rohan-hotdata
rohan-hotdata requested review from anoop-narang and removed request for a team September 3, 2026 13:53
Comment thread hotdata_langchain/databases.py Outdated
Comment on lines +361 to +380
seen: 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
seen.add(str(summary.id))
cursor = getattr(listing, "next_cursor", None)
# has_more alone has been seen paired with no cursor; without this the loop would
# either stop early or repeat the first page forever.
if not cursor or not listing.databases:
return
if len(seen) > _MAX_DATABASES_SCANNED:
logger.warning(
"stopped paging database listings after %d records; expiries are partial",
len(seen),
)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The scan cap counts distinct ids, so it does not stop a repeating cursor.

Failure scenario: the server answers every request with the same page of 100 summaries and the same next_cursor. seen holds 100 ids and never grows. cursor stays truthy and listing.databases stays non-empty. database_expiries loops forever and never returns, because the dict comprehension keeps overwriting the same 100 keys.

Count records read instead of distinct ids, and stop on a cursor already used:

Suggested change
seen: 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
seen.add(str(summary.id))
cursor = getattr(listing, "next_cursor", None)
# has_more alone has been seen paired with no cursor; without this the loop would
# either stop early or repeat the first page forever.
if not cursor or not listing.databases:
return
if len(seen) > _MAX_DATABASES_SCANNED:
logger.warning(
"stopped paging database listings after %d records; expiries are partial",
len(seen),
)
return
cursor: str | None = None
scanned = 0
used_cursors: 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 = getattr(listing, "next_cursor", None)
# has_more alone has been seen paired with no cursor; without this the loop would
# either stop early or repeat the first page forever.
if not cursor or not listing.databases:
return
if cursor in used_cursors:
logger.warning("database listing repeated cursor %r; expiries are partial", cursor)
return
used_cursors.add(cursor)
if scanned > _MAX_DATABASES_SCANNED:
logger.warning(
"stopped paging database listings after %d records; expiries are partial",
scanned,
)
return

Add a test for the repeating-cursor case. The current paging tests all end the sequence with a page that has no cursor, so none of them exercises the cap.

Comment thread hotdata_langchain/databases.py Outdated
for summary in listing.databases or ():
yield summary
seen.add(str(summary.id))
cursor = getattr(listing, "next_cursor", None)

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: getattr with a None default hides a field-name mismatch (not blocking). If the SDK response model names the field something other than next_cursor, paging stops after page one and reports a subset as complete, which is the exact failure this function exists to prevent. Nothing catches it, because the tests build pages from SimpleNamespace rather than the SDK model. Read listing.next_cursor directly so a mismatch raises AttributeError, or assert the field name against the declared SDK floor in a test.

Comment thread hotdata_langchain/databases.py Outdated
Comment on lines 69 to 74
MAX_DOWNLOAD_BYTES = 1024**3

#: A stop on paging database listings, so a server that keeps returning a cursor cannot
#: spin this forever.
_MAX_DATABASES_SCANNED = 10_000
DOWNLOAD_CHUNK_BYTES = 1024 * 256

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: the new constant splits the download constants (not blocking). MAX_DOWNLOAD_BYTES and DOWNLOAD_CHUNK_BYTES belong together. Move _MAX_DATABASES_SCANNED below DOWNLOAD_CHUNK_BYTES.

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

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: the sentence starting "For the attach pair" has no main clause (not blocking). "For the attach pair that is the open question in #61 ... — which this release does not settle" reads as a fragment. Rewrite as: "For the attach pair, whether provisioning should be agent-callable at all is the open question in #61, which this release does not settle."

@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.

Review

Blocking Issues

  • hotdata_langchain/databases.py:361-380 — the 10,000 record cap counts distinct database ids, not records read. A server that returns the same page and the same cursor on every request never grows seen, so database_expiries loops forever. The cap is the stated defence against that case and does not cover it.

Action Required

  1. Count records read, or reject a cursor already used, so a repeating cursor terminates the loop.
  2. Add a test where the server keeps returning the same cursor. The three existing paging tests all end on a page with no cursor, so none reaches the cap.

Non-blocking comments are inline: the getattr(listing, 'next_cursor', None) default, constant placement, and one CHANGELOG sentence.

CI checks were queued or in progress when this review ran, so the test results are unknown here.

The record cap counted distinct database ids, so it did not stop the case it was
written for. A server answering every request with the same page and the same
cursor never grows the id set, so the loop ran forever and the dict comprehension
overwrote the same keys. The code comment claimed the opposite.

Two changes: count records read rather than distinct ids, and return when a
cursor arrives a second time. Four tests cover termination, including one page
repeated forever, two cursors alternating, and a fresh cursor every time so only
the record cap can end it. The three existing paging tests all ended on a page
with no cursor, so none of them reached this code.

Also read listing.next_cursor directly rather than through getattr. A
field-name mismatch would otherwise stop paging after one page and report a
subset as complete, which is the failure this function exists to prevent. The
field is present on ListDatabasesResponse at the declared floor.

Keeps the download byte constants adjacent, and fixes a sentence fragment in the
CHANGELOG.
@rohan-hotdata

Copy link
Copy Markdown
Contributor Author

All four findings addressed in 58c2235.

Blocking — the cap did not stop a repeating cursor. Correct, and worse than a missing guard:
the cap was written for exactly that case, and the code comment claimed it covered it. len(seen)
counted distinct ids, so a server answering every request with the same page and the same cursor
never grew the set, the loop never ended, and the dict comprehension kept overwriting the same
keys.

Fixed both ways the review suggested. Records read are now counted instead of distinct ids, and a
cursor arriving a second time ends the loop.

Four tests, since the point stands that none of the three existing paging tests reached this code
— they all ended on a page with no cursor:

  • one page returned forever with the same cursor, which terminates after 2 requests
  • two cursors alternating, caught on the third request
  • a fresh cursor every time, so only the record cap can end it
  • the cursor sequence actually sent, asserted as [None, "c1", "c2"]

getattr(listing, "next_cursor", None). Taken, and the reasoning was the right way round: a
field-name mismatch would stop paging after one page and report a subset as complete, which is the
single failure this function exists to prevent. Now read directly. Since that makes correctness
depend on the field name, I checked it against the declared floor rather than the lockfile —
ListDatabasesResponse at hotdata 0.9.0 has count, databases, has_more, limit,
next_cursor.

Constant placement. Moved below DOWNLOAD_CHUNK_BYTES so the download constants stay
together. Its comment now records why the cap counts records rather than ids, so the next reader
does not reintroduce the bug above.

CHANGELOG fragment. Taken as written; the sentence had no main clause.

Re-run locally: 638 passed (up 4), mypy clean, ruff clean, and the --resolution lowest-direct
floor job green.

On the CI caveat in the review: all nine checks passed on the previous head, including the full
3.10–3.14 matrix and the floor job.

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.

claude[bot]
claude Bot previously approved these changes Sep 3, 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.

All four prior comments are addressed. The loop now counts records, reads listing.next_cursor directly, stops on a repeated cursor, and the new tests cover the repeating cursor, the two-cursor cycle, and the record cap. One non-blocking docstring nit inline.

database_expiries promised the workspace, and both paging guards end early with
a logger.warning only, so a caller can receive a truncated mapping with no
programmatic signal. Telling a complete read from a partial one is the stated
reason the function exists, so the guards now appear in its docstring, in the
README and in the CHANGELOG entry rather than only in the code.

@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.

All five prior findings are addressed.

The repeating-cursor loop now terminates two ways: records read are counted rather than distinct ids, and a cursor arriving a second time ends paging. The four new tests cover both stops and the cursor sequence sent.

CI was still queued or in progress when this review started, so this approval does not rest on the test results.

@rohan-hotdata
rohan-hotdata merged commit 8801a53 into main Sep 3, 2026
10 checks passed
@rohan-hotdata
rohan-hotdata deleted the feat/database-expiry-readback branch September 3, 2026 15:41
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.

1 participant