Task27956 prevent repeat api calls - #15
ZachSchwartz wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved deduplication, cache validation, key-collision, and malformed-response issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR adds geocoding response caching and duplicate-request suppression, with provider refactoring and expanded tests.
Changes:
- Adds SQLite cache modes and CLI integration.
- Refactors Google and Census request handling.
- Expands cache, provider, and CLI tests; ignores cache artifacts.
File summaries
| File | Reviewed change | Final review note |
|---|---|---|
test/google_test.py |
Google caching and raw-response tests | — |
test/geocoder_test.py |
CLI cache tests | — |
test/census_test.py |
Census caching tests | — |
test/cache_test.py |
Cache behavior tests | — |
test/api_test.py |
Provider API tests | — |
src/google.py |
Google request and response handling | Moderate, 1 vote: Empty OK responses can be cached and fail on later runs. |
src/geocoder.py |
Cache-related CLI integration | — |
src/census.py |
Census caching and parsing | Critical, 3 votes: Delimiter-based cache keys can collide for addresses containing |. |
src/cache.py |
SQLite cache implementation | Moderate, 1 vote: Connection errors can escape CLI handling. Moderate, 2 votes: Existing tables are not validated against the required schema. |
src/api.py |
Shared caching and deduplication logic | Moderate, 1 vote: Deduplication is not shared across worksheet calls when --noCache is used. |
.gitignore |
Ignores SQLite cache artifacts | — |
Review details
Suppressed comments (3)
src/api.py:227
- The deduplication map is local to one
Provider.geocodecall, butprocess_workbookcalls the provider once per worksheet. Therefore identical queries on two sheets are still fetched twice when--noCacheis used, despite the CLI help promising repeated addresses are collapsed within the run. Share an in-memory run-level cache/deduplication map across sheet calls, while keeping it non-persistent for--noCache.
keys = [normalize_query(self.cache_key(record)) for record in records]
responses = self.cache.lookup(self.name, keys)
src/cache.py:88
sqlite3.connect(path)is outside the guarded block, so an invalid cache location such as a directory or a path whose parent is missing raisessqlite3.OperationalErrordirectly.mainonly convertsValueErrorfromCacheintoSystemExit, so these user input errors produce an uncaught traceback instead of the intended CLI error; move the connection open into the error-wrapping path and close it on failure.
connection = sqlite3.connect(path)
src/google.py:94
OKresponses are yielded without checking that a result exists. If Google returns an invalid emptyOKenvelope,Provider.geocodestores it beforeparse()indexesresults[0], and every later run fails on the same bad cached response instead of retrying. Reject an emptyresultslist before yielding so malformed responses are never cached.
status = payload.get("status", "UNKNOWN")
if status not in ("OK", "ZERO_RESULTS"):
raise ValueError(f"Google geocoding failed with status {status!r}: {payload.get('error_message', '')}".strip())
yield record, payload
- Files reviewed: 10/11 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| The query text identifying this record's response. | ||
| """ | ||
| parts = [record.address, record.city, record.stateprov, record.postalcode, self.BENCHMARK, self.VINTAGE] | ||
| return "|".join(parts) |
| try: | ||
| found = connection.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cache'").fetchone() | ||
| except sqlite3.DatabaseError as error: | ||
| connection.close() | ||
| raise ValueError(f"'{path}' is not a readable SQLite database: {error}") from error | ||
|
|
mwaddell
left a comment
There was a problem hiding this comment.
Address comments and fix merge conflicts
| SCHEMA = """ | ||
| CREATE TABLE IF NOT EXISTS cache ( | ||
| api TEXT NOT NULL, | ||
| query TEXT NOT NULL, | ||
| original_query TEXT NOT NULL, | ||
| response TEXT NOT NULL, | ||
| fetched_at TEXT NOT NULL, | ||
| PRIMARY KEY (api, query) | ||
| ) | ||
| """ |
There was a problem hiding this comment.
considering the table structure, i'm worried that this file could grow indefinitely. Since the key will change whenever the version/benchmark changes for an API that would effectively "obsolete" a whole group of the entries but there would be no way to differentiate them from others.
I'm thinking that it would be useful to split up the key into an address portion (what we're sending to the API) and a version portion (the metadata about how we're calling the API).
It would probably also be more efficient to store each api in its own table and just have a single key column as the primary key. So, the API and version stuff should be specified in the init (since it won't change), then the init would make sure the corresponding api table exists and add it if it doesn't.
The table itself would contain a query primary key and a version tag. When calling "get", you would pass just the query portion. The API would do a "SELECT query, response FROM {apitable} WHERE version = {singleversion} and query in ({placeholders})" - that way it won't pull those where the version is wrong, but when you go to store them later it will overwrite any with the wrong version.
There was a problem hiding this comment.
This means that cache_key would actually be identical across all APIs (so api.py can calculate it in one place) and each API implementation just needs to report it's "version id" (which would contain anything that doesn't come from the address that would change as the API itself changes)
| """ | ||
|
|
||
|
|
||
| def normalize_query(query: str) -> str: |
There was a problem hiding this comment.
This is never used except by api.py so it should probably live in there.
| country_per_sheet=args.country_per_sheet, | ||
| debug=args.debug, | ||
| ) | ||
| cache_path = None if args.no_cache else args.cache or DEFAULT_CACHE_FILE |
There was a problem hiding this comment.
We'll need to note in the documentation that --cache and --cacheRead are ignored when --noCache is used since we don't note that this is an error
https://redmine.pangaeatech.com/issues/27956