feat: read back when an instant database is due to be reaped - #98
Conversation
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.
| 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 |
There was a problem hiding this comment.
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:
| 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.
| for summary in listing.databases or (): | ||
| yield summary | ||
| seen.add(str(summary.id)) | ||
| cursor = getattr(listing, "next_cursor", None) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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."
There was a problem hiding this comment.
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 growsseen, sodatabase_expiriesloops forever. The cap is the stated defence against that case and does not cover it.
Action Required
- Count records read, or reject a cursor already used, so a repeating cursor terminates the loop.
- 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.
|
All four findings addressed in 58c2235. Blocking — the cap did not stop a repeating cursor. Correct, and worse than a missing guard: Fixed both ways the review suggested. Records read are now counted instead of distinct ids, and a Four tests, since the point stands that none of the three existing paging tests reached this code
Constant placement. Moved below CHANGELOG fragment. Taken as written; the sentence had no main clause. Re-run locally: 638 passed (up 4), mypy clean, ruff clean, and the On the CI caveat in the review: all nine checks passed on the previous head, including the full |
| 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. |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
The package can set a database lifetime and cannot read one back. This adds
hl.database_expiryandhl.database_expiries.Follows #97, which is already merged. Both ship in the same release.
The gap
expires_atis written as a string, either an RFC 3339 timestamp or a relative window suchas
"24h". It comes back as adatetime:CreateDatabaseRequest.expires_atstr | NoneDatabaseDetailResponse.expires_atdatetime | NoneDatabaseSummary.expires_atdatetime | NoneManagedDatabaseThe 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 holdinga resolved
ManagedDatabasecannot 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
database_expiryreuses_database_detail, the same routedatabase_attachmentstakes.database_expiriescosts one request per page of the listing, not one per database, becauseDatabaseSummaryalready carries the field. A database with no TTL maps toNonerather thanbeing 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:Three problems: it returns
ManagedDatabaseand so dropsexpires_at; it issues aget_databaseper database; and it swallowsApiExceptionper database, so one whose detail readfails 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_databasessits on that path.Pagination
list_databasestakeslimit,cursorandsearch, so a single-page read would report a subsetof the workspace as though it were complete.
database_expiriesfollows the cursor. It also stopson 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 choosesonly when to act.
The asymmetry also favours waiting. Adding the field to
hotdata_list_managed_databaseslaterstays additive. Removing it later would change what every model sees.
Verification
--resolution lowest-directDocs
from feat: attach a registered source into a database's scope, and read back when it expires #97.
[Unreleased]. While there I fixedthree things in that block:
database_attachmentssat separated from the attach pair it belongsto, the closing paragraph claimed Provisioning boundary: agent-driven database, table, and index lifecycle #61's open question covered all of it when Provisioning boundary: agent-driven database, table, and index lifecycle #61 is about
provisioning rather than these read-backs, and the expiry entry repeated the
"not on a tool" line the closing paragraph already made.
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.