Repository navigation
fix: real, storage-backed catalog for synced ad servers (AI-6) - #117
Conversation
…el/others get static demo catalog
…image installs the gam extra (AI-6)
…g-for-synced-ad-servers # Conflicts: # CHANGELOG.md # src/ad_seller/interfaces/api/deps.py # src/ad_seller/interfaces/api/main.py # src/ad_seller/interfaces/cli/main.py # src/ad_seller/services/catalog_service.py
Kishore-2572
left a comment
There was a problem hiding this comment.
Live-tested the safety-critical part given this codebase's history with #100: simulated an ad-server call failing mid-fetch with a real product already persisted — confirmed the real product survives, the failure path never reaches the pruning logic.
Closed the cross-repo gap live: drove the real unmodified persistence/serving functions with realistic fake GAM inventory into a real DB, started a real server against it, confirmed
GET /productsreturns real synced products not the static catalog, then got a correct quote through real buyer-agent code end to end.GAM discovery fix and Dockerfile
gamextra both confirmed in code. Full suite: 1726 passed, 0 failed, matches the PR's numbers. Approving on substance — no blocking issues found.
|
A successful sync that returns no inventory deletes the stored products, and the next catalog read serves the static defaults again. |
…talog, not the static demo products (AI-6) A successful sync that finds zero inventory items left zero synced_product:* keys, indistinguishable from "never synced" -- build_synced_product_catalog() fell back to the 13-product static demo catalog, silently serving fake products as if real. Add a synced_catalog:completed marker, set only on the real ad-server success path (never the mock path). The catalog is now treated as real/synced if EITHER a synced_product:* key exists (covers products persisted mid-sync, before the marker is set) OR the completion marker is present (covers a completed sync that genuinely found nothing) -- only "neither" still falls back to the static catalog. Found by aleksUIX in PR #117 review. Verified with a new unit regression test plus a full e2e pass (real FastAPI app, real sqlite storage, real sync method, only the GAM network call faked): sync with items -> empty sync -> simulated transient failure -> recovery sync, confirmed correct at each step, including from a second independent server process reading the same storage.
…report it as failed (AI-6) persist_synced_product/_upsert_synced_package write straight to storage one item at a time inside the loop. An exception partway through left every item already written by THIS run permanently in storage, even though the sync is reported as failed -- contradicting the existing claim that a failure never silently changes the synced layer. Capture each key's prior value before writing it, and on any exception, roll back precisely: restore the prior value if the key already existed (a re-synced item can share an id with one an earlier successful sync persisted), or delete it if this run introduced it fresh. Same fix applied to both the product loop and the package loop (identical structural risk). Reproduced live: synced A+B cleanly, then a sync that persists C and crashes before D -- A and B survived as expected, but C leaked in anyway. Fixed; added as two permanent regression tests (the leak case, and the re-synced-existing-id-restores-correctly case). Full suite: 1729 passed, 0 failed.
|
Good catch, thanks for catching this! Fixed — a sync completing with zero items now serves an honest empty catalog instead of falling back to the demo one, via a Also found and fixed a related issue while verifying: a sync that crashes partway through persisting items was leaving the already-persisted new items in storage despite being reported as failed. Now rolled back precisely on failure. Both covered by new tests, verified end-to-end. Commits: |
|
An empty successful sync now stays empty. A finished real sync sets synced_catalog:completed, including when the ad server returns no items, and the catalog read returns that empty catalog instead of the static defaults. The new test covers a one-product sync followed by an empty success. |
atc964
left a comment
There was a problem hiding this comment.
Really solid work, Siraj. The three-layer diagnosis (catalog read path, GAM discovery URL, missing Docker extra) is convincing, and verifying all three together against a live GAM network from a scratch-built image is exactly the standard we want. I checked the Redis backend's keys() prefix handling specifically since the in-memory test double would mask a prefix bug there, and it's clean; hybrid routing also puts synced_product:* in Postgres, which is the right durability call. The rollback tests proving prior-value restoration (not blind deletion) are a nice touch.
One fix I'd like before merge, three things to track:
-
(this PR) The
_finish_syncdocstring says it "runs unconditionally, including... (or the real sync raised)" and that a transient failure "reverts the catalog to the honest static-default fallback", but the except block insync_from_ad_serverreturns before_finish_sync, and its own comment says the opposite (correctly!). The implemented keep-last-good behavior is the right one; the docstring describes the failure mode this PR exists to prevent. Could you reword it so the next reader doesn't "fix" the code to match it? -
(decision/follow-up) This PR is the moment real floors start flowing to anonymous surfaces:
GET /productspublishesext.floor_cpmand the ungated MCPlist_productsreturnsfloor_cpmdirectly, and with #92 clamping quotes to the floor, that's publishing the seller's reserve price. The exposure predates your change (CSV mode already does it), so it doesn't block here, but we should make a deliberate call (keep as published rate card vs trim floor_cpm from public mappers) before a real network runs in prod. We're tracking the decision on our side. -
(follow-up) CSV mode still goes down the real sync path, so a CSV-mode sync persists
synced_product:*keys and sets the never-cleared completed marker that the CSV read path ignores. If a deployment later switches to GAM/S3, those dormant CSV-era products get served as the synced catalog until the first sync under the new config, and with the scheduler off by default that window is unbounded. Skipping persist/marker whenad_server_type == "csv"would close it cheaply. -
(follow-up, noting only) With two workers and the scheduler enabled, both workers sync on the same interval from boot, and the new rollback can restore "prior" values captured before a concurrent run's writes. It self-heals next cycle, but a
lock:key (hybrid already routes those to Redis) or jitter would make it boring. Related small one: the synced read path iskeys()plus onegetper product per catalog read, the Redis backend uses KEYS rather than SCAN/MGET, andProductDefinition(**data)will 500 every catalog endpoint if a single stored record ever drifts from the model; a per-key try/log/skip would degrade more gracefully.
Also a two-second one: the media_kit.py comment saying the catalog "is never persisted to storage" is now stale.
Approving with the docstring fix. The multi-worker read-consistency design is right: fresh storage read per call, no cache to invalidate, every consumer goes through the one accessor. I grepped main for bypasses and found none.
0677a0e to
8c1bfa6
Compare
|
Thanks Siraj, I re-reviewed Verified: an empty sync serves an empty catalog, mid-loop crashes roll back to prior values, products removed upstream are pruned, a failed sync keeps the last good catalog, and buyer-agent lists and quotes synced products. The docstring fixes are in. BlockingGAM syncs only the first 100 ad units, and the prune deletes the rest. Should fix (here or tracked)
atc964's open follow-ups still apply: the floor is shown to anonymous callers, CSV mode persists synced keys, and two workers can race. |
… 100 (AI-6) GAMAdServerClient.list_inventory read one page and dropped nextPageToken, so a sync saw at most 100 ad units and the prune deleted the rest. It now follows the token until it runs out or limit units are collected; limit=None reads every page. The sync requests limit=None, which also lifts the same 100-row cap on S3.
The products loop commits before the packages loop runs, and only the products loop's own failure rolled products back. A crash in the packages loop left this run's products stored while the sync reported failure. Both loops now share the product rollback.
|
Thanks Kishore, confirmed both and fixed in
|
The sync now calls list_inventory(limit=None), but the CSV adapter compared len(results) >= limit unconditionally, so every CSV-mode sync failed with a TypeError. Guard the comparison, and add a sync test through the real CSVAdServerClient so a flow/adapter limit mismatch can't hide behind the test fakes again.
…AI-6)
build_csv_product_catalog called list_inventory() with the adapter's default limit of 100, so a CSV file with more rows served only the first 100: later products 404'd on GET /products/{id} and quotes, while the sync (which now reads every row) still built packages pointing at them. Request limit=None so the catalog and the sync see the same inventory.
Kishore-2572
left a comment
There was a problem hiding this comment.
Re-reviewed at 67be66c and tested end to end on a real seller server (SQLite), with syncs through the real ProductSetupFlow and real adapters. Approving.
Verified
| Item | Result |
|---|---|
| GAM paging | 250 ad units → all 3 pages read, 250 products stored; a failure on a later page keeps the last good catalog; a repeating page token terminates |
| Packages-loop failure | This run's products are rolled back; only the prior catalog remains |
CSV sync (limit=None) |
Real server, CSV mode: /packages/sync → synced, inventory-sync trigger → success (15 items) |
| CSV catalog past 100 rows | Real server, 150-row CSV: GET /products → 150, row 150 served and quotable |
| Empty sync / failed sync | Empty catalog served honestly; a failed sync keeps the last good catalog |
| Cross-repo | Real buyer-agent code lists synced products and gets a quote |
Negative controls on the new tests (paging, packages rollback, CSV limit=None, CSV catalog) all fail when the fix is reverted.
CI
- All 5 checks green on
67be66c, including Docker Build. - Merged with current
main: no conflicts; 1797 unit + 39 integration tests pass on Python 3.11 and 3.12;ruff checkandruff format --checkclean.
Problem
GET /products(and everything reading the catalog) served the static 13-product default catalog even after a real ad-server sync (GAM/FreeWheel/S3) had run.ProductSetupFlow.state.productsis per-flow-instance and never outlives the request, and the catalog service's in-memory cache is per-worker-process (infra/docker/Dockerfileruns uvicorn with--workers 2), so at most one of two worker processes ever saw synced data. Docs claimed GAM/FreeWheel support that the code didn't deliver.Fix
Three layers, all required together for a real GAM deployment to actually work:
synced_product:{id}storage key (deliberately separate from the genericproduct:{id}keynegotiation_service/media_kit_servicealready write, which is a narrower proposal-time snapshot) and are served fresh from storage on every catalog read when a real ad server is configured, matching how synced packages already worked. Stale products no longer re-seeded by a sync run are pruned instead of lingering as phantom inventory. CSV-mode and no-ad-server-configured behavior is unchanged.GAMRestClient.connect()calledgoogleapiclient.discovery.build("admanager", "v1", ...)with nodiscoveryServiceUrl. GAM self-hosts its own discovery document rather than registering centrally, so every real connection raisedUnknownApiNameOrVersionand silently degraded to mocks. Fixed by pointingbuild()atadmanager.googleapis.com/$discovery/rest?version=v1directly.infra/docker/Dockerfileonly installed theredisextra; thegamextra (googleads/google-api-python-client/google-auth) was never in the shipped production image, so even with (1) and (2) fixed, the container wouldImportErrorbefore ever reaching GAM. Fixed by installing".[redis,gam]".All three verified together end to end against a real GAM network with real service-account credentials, rebuilding the real Dockerfile from scratch with zero manual installs:
list_inventory()returned 100 real ad units,POST /packages/synccompleted with zero warnings,GET /productsserved 50 real synced products.Not yet verified: FreeWheel's real-data path — no real FreeWheel credentials available. Its
list_inventory()looks architecturally sound (real OAuth 2.1 MCP tool call) but unproven live.Reconciliation with main
This branch had drifted behind
main. Mergedorigin/mainin, resolving conflicts inCHANGELOG.md,interfaces/api/deps.py,interfaces/api/main.py,interfaces/cli/main.py, andservices/catalog_service.py— all were additive overlaps (new unrelated routes/fixes landing alongside this branch's own catalog work), not competing logic changes.Reconciling against main's newer
default_price_floor_cpmsetting (AI-8) surfaced one real gap:tests/unit/test_synced_product_catalog.py's hand-rolled settings fixture predated that field, so the second sync in the prune test crashed before pruning ever ran — masking as a "pruning doesn't work" failure when the actual cause was an unrelated settings-fixture gap. Fixed by adding the field to the fixture.Testing
/healthand/productsrespond correctly post-merge.