Repository navigation
feat(server): index the agent catalog from the identity registry (#17) - #118
Conversation
The catalog endpoint read the whole catalog from the chain on every request and only cached it in memory. Add a durable Postgres index synced from the identity registry, with a resume cursor, and serve list/get from it. - Ledger: `getLatestLedger` and `getEvents` (`xdrFormat: json`) on `SorobanRpcClient` (ledger range xor cursor); `registry_events.dart` decodes `registered`, `metadata_set` and `uri_updated`; `RegistryEventReader` port + `SorobanRegistryEventReader` (cursor pagination). - Index: `AgentRecord` and `CatalogIndexState` models + migration, `AgentIndexRepository` port and `ServerpodAgentIndexRepository` (idempotent upsert by registry id, per-network resume cursor). - Indexer: `CatalogIndexer` bootstraps from registry state (RPC keeps only ~7 days of events), then reads events after the cursor and hydrates the affected agents from state; any agent missing from the index is recovered. Chain outages abort the pass and leave the index intact; a retention gap resets the cursor and recovers from state. - Read path: `CatalogReader` port, `IndexedAgentCatalogService` (index first, on-chain fallback while empty), `agent_catalog_wiring.dart`; `AgentEndpoint` depends only on `CatalogReader`. - Loop: `CatalogIndexerLoopConfig` + `startCatalogIndexer`, started from `server.dart` behind `PULS3_INDEXER_ENABLED` (off by default). - Tests: unit tests for the RPC events, parser, indexer and indexed service; integration tests for the repository and the endpoint. Recorded testnet fixtures for `getLatestLedger` and a registry `getEvents` page. - Docs: `PULS3_INDEXER_*` in `.env.example`, server README, api.md note, and openspec change artifacts. Refs #17
TOMOKI977
left a comment
There was a problem hiding this comment.
Thanks Fernando, the overall design is solid. The cursor is written only after the upsert, bootstrap runs from registry state, events are filtered by the registry contract id, the models are serverOnly, and all writes go through the ORM. Requesting changes for a few edge cases and one regression.
Must fix
-
The catalog cache regressed.
buildAgentCatalogReader(agent_catalog_wiring.dart:48) runs on every request and builds a newAgentCatalogService(ledger)each time. The 60s cache, the shared in-flight refresh and serve-last-list-on-outage were all instance state, so they are gone on the fallback path.PULS3_INDEXER_ENABLEDdefaults tofalse, which means the default deployment reads the whole catalog from the chain on everylist/get. The test that guarded "the default service is reused" was removed. Please build the fallback once per process (e.g. a lazily created singleton) and restore that test. -
getbreaks the index-first contract.IndexedAgentCatalogService.get(line 30) falls back to the chain on any index miss, even when the index has rows. Theindexed-agent-catalogspec and the README say reads must not touch the chain while the index has rows. Combined with point 1,get('unknown-id')triggers a full uncached chain read, and during an outage it throws instead of returningnull. The testget serves the index, then the fallbackasserts the opposite of the spec. Please only fall back when the index is empty and update that test. -
A gap in the event stream leaves stale rows forever. In
catalog_indexer.dart:84-93, anyRpcRequestRejected(that is, -32600/-32602, not only an out-of-retention start ledger) advances the cursor tolatest. Only agents missing from the index are recovered. An already-indexed agent whose price, wallet or name changed inside the gap keeps serving the old values, because nothing ever re-reads it.SorobanRegistryEventReaderalso stops at_maxPageswithout signaling truncation (soroban_registry_event_reader.dart:45), so the cursor advances the same way. Suggested fix: on a gap, or on truncation, re-hydrate every indexed id (not just the missing ones), and make truncation an explicit result or exception. Please add a test where the index already has the agent and its metadata changes during the gap. -
The outage test does not await. In
catalog_indexer_test.dart:246-255,expect(indexer(...).pass(), throwsA(...))is not awaited. The "index untouched / checkpoint null" assertions therefore run before the pass executes and prove nothing. Useawait expectLater(...)in anasynctest.
Should fix (non-blocking)
- Orphan or invalid registrations never reach the index, so every pass re-reads them over RPC (about 7 sequential calls each, every 30s). Consider persisting skipped ids, or keeping a tombstone row that the read path excludes.
- When an indexed agent's metadata becomes invalid, it is counted as
skipped, but its old row keeps being served. The repository needs a delete or invalidate path. AgentRecordis unique byregistryIdonly. IfPULS3_STELLAR_IDENTITY_REGISTRYchanges, old rows count as already indexed. Scope rows by network and registry contract, like the cursor.- There is no single-runner guard. With multiple instances, the select-then-insert in
upsertAllcan hit unique violations. Document "one indexer instance", or use an upsert withON CONFLICT. - There are no tests for
SorobanRegistryEventReader(pagination, cursor, bound), for either wiring module, or for theLedgerExceptionpath where the cursor stays put. - Layering:
agent/registry_event_reader.dartimportsledger/registry_events.dart, while the adapter inledger/imports fromagent/. Moving the event types next to the port (or into a neutral module) removes the cycle. - Docs:
api.md:26saysCatalogUnavailablewhere it should sayAgentCatalogUnavailable.api.md:77andapi.md:343still describe the in-memory cache and a "planned in #17" stale warning. The proposal listsagent-catalog-endpointas modified but has no delta spec for it. Gates 6.1-6.3 intasks.mdare unchecked. - Nits:
_rpcTimeoutis now defined in three places, there are unused event fields (owner,uri,key,value,txHash), and some lines exceed 80 columns.
Process
- Size: about 2.3k authored lines, against the ~700 in the forecast. Your tasks already suggest a clean 3-way split (RPC events → index + indexer → wiring). Splitting would make the re-review much faster.
- The branch is based on
c328902and is behind main (#117 landed). After the rebase, please check that the migration timestamps still sort after the ones on main, and regenerate if needed.
Happy to pair on point 3 if useful.
…-index # Conflicts: # puls3_server/migrations/migration_registry.txt
Address the review on #118 and update the branch with main. - Cache regression: the on-chain fallback is now a process-wide singleton, so its 60 s cache, shared in-flight refresh and serve-last-list-on-outage survive across requests; a wiring test asserts it is built once. - Index-first `get`: it falls back to the chain only when the index is empty, so an unknown id on a populated index returns `null` without reading the chain (test updated to the spec). - Event-stream gaps: `RegistryEventReader.eventsSince` returns a `RegistryEventBatch` whose `truncated` flag makes a page-bound stop explicit; on a retention gap or truncation the indexer re-hydrates every indexed agent (not only the missing ones), so an agent whose metadata changed in the gap is refreshed. Tests cover both. - The outage test now awaits the pass (`expectLater`). - Moved the registry event types next to the port, removing the agent→ledger import cycle (`ledger/registry_events.dart` re-exports them). - Docs: `api.md` says `AgentCatalogUnavailable` and describes the index-first outage behavior; added a delta spec for `agent-catalog-endpoint`; checked the gate boxes in `tasks.md`. - Updated with main and regenerated the migration after the escrow-relay one. Refs #17
|
Thanks! Addressed the review in 72c0792 (updated with main):
|
|
Thanks for addressing the earlier review. The cache, index-first lookup, gap recovery, and awaited outage test are fixed. Two blockers remain:
After those fixes, update the branch from |
moises-cisneros
left a comment
There was a problem hiding this comment.
Status after resolving the merge conflicts (56a5494).
Conflicts
server.dart: kept both the catalog indexer and the wallet auth imports.- Generated protocol code and migrations: took main's version, then regenerated with
serverpod generate. The old migration20261009170004821was computed without main's wallet tables, so I replaced it with20261009213303097, which creates onlyagent_recordandcatalog_index_state. - The PR is
MERGEABLE.dart analyzeis clean and the 483 server unit tests pass locally.
Earlier review (must-fix items)
Checked against the code on the current head:
- The on-chain fallback is a process-wide singleton, built once.
getfalls back to the chain only when the index is empty.- A gap or a truncated event page re-hydrates every indexed agent.
- The outage test uses
await expectLater.
The non-blocking items and the size/split suggestion are not covered here.
CI is red for infrastructure reasons, not for this diff
server: fails at "Initialize containers" withtoomanyrequests(Docker Hub unauthenticated pull rate limit) while pullingpgvector/pgvector:pg16. The tests never ran.docker:504 Gateway Timeoutfromauth.docker.iowhile resolvingdartandalpine.gatefails only because it requires those two.feat/20-hire-runnerfails the same way at the same time. Jobs that do not touch Docker (app,scripts,changes,scan) pass.- Failed jobs were re-run three times with the same result.
Next steps
- Re-run the failed jobs once Docker Hub recovers, and confirm
serverandgatego green. - Longer term, authenticate the pulls (
credentials:on the services anddocker/login-action) with a Docker Hub token stored as a repo secret. That belongs in a separate CI PR. - @TOMOKI977 a re-review of the four items above would unblock this once CI is green.
Refs #17
Summary
The catalog endpoint read the whole catalog from the chain on every request and
only cached it in memory. This adds a durable Postgres index synced from the
identity registry, with a resume cursor, and serves
list/getfrom it whilekeeping the current endpoint surface unchanged.
getLatestLedgerandgetEvents(xdrFormat: json) onSorobanRpcClient(ledger range xor cursor);registry_events.dartdecodesregistered,metadata_set,uri_updated;RegistryEventReaderport +SorobanRegistryEventReader(cursor pagination).AgentRecord+CatalogIndexStatemodels and migration;AgentIndexRepository+ServerpodAgentIndexRepository(idempotent upsert byregistry id, per-network resume cursor, newest-registration dedupe).
CatalogIndexerbootstraps from registry state (the RPC keepsonly ~7 days of events), then reads events after the cursor and re-hydrates
the affected agents from state; any agent missing from the index is always
recovered. A chain outage aborts the pass and leaves the index intact; a
retention gap resets the cursor and recovers from state.
CatalogReaderport,IndexedAgentCatalogService(indexfirst, on-chain fallback while empty),
agent_catalog_wiring.dart;AgentEndpointdepends only onCatalogReader.CatalogIndexerLoopConfig+startCatalogIndexer, started fromserver.dartbehindPULS3_INDEXER_ENABLED(off by default).PULS3_INDEXER_*in.env.example, server README,api.mdnote,and openspec change artifacts under
openspec/changes/17-agent-catalog-index/.Acceptance criteria
8 agents (registry ids 7–14), 7 orphans skipped. Output in evidence below.
last processed ledger. Both are tested (unit + integration).
logs the error and retries on the next run. Tested.
AgentCatalogUnavailableunchanged).AgentEndpointnowdepends only on
CatalogReader.Verification evidence
Run in
puls3_server(local Postgres matchingconfig/test.yaml):$ dart analyze --fatal-infos
No issues found!
$ dart test test/unit
320 tests passed
$ dart test -j 1 test/integration
agent_index_repository_test, agent_endpoint_index_test, hire_repository_test,
chain_submission_store_test, chain_submission_tracker_restart_test,
greeting_endpoint_test, health_endpoint_test -> all passed
$ serverpod generate && git diff --exit-code
no diff (reproducible)
$ serverpod create-migration
"Server migration skipped. No changes detected."
Live testnet smoke run of
CatalogIndexeragainst a real RPC (in-memoryrepository, no DB):
catalog pass: from bootstrap to 5081240, events 0, touched 15, hydrated 8, skipped 7
catalog (8 agents):
#7 agt-001 | Ledger Scout | on-chain-analytics, monitoring, summaries | 5000000 stroops
#8 agt-002 | Remit Pilot | payments, anchors, compliance | 12500000 stroops
#9 agt-003 | Soroban Auditor | smart-contracts, security, rust | 45000000 stroops
#10 agt-004 | Invoice Clerk | document-parsing, payments, accounting | 2500000 stroops
#11 agt-005 | Market Pulse | on-chain-analytics, trading, summaries | 7500000 stroops
#12 agt-006 | Copy Forge | copywriting, marketing, summaries | 3000000 stroops
#13 agt-007 | Support Relay | customer-support, triage, monitoring | 1000000 stroops
#14 agt-008 | Data Weaver | data-cleaning, accounting, document-parsing | 6000000 stroops
Recorded testnet fixtures are committed for the RPC tests:
test/unit/ledger/fixtures/get_latest_ledger.jsonand.../get_events_registry_register_7.json.Notes for reviewers
surface. Pagination and server-side search are deferred: the merged docs: API contract between Flutter app and Serverpod #8
contract (
CatalogEndpoint.listAgentsunpaginated; F2-3/F2-4 filterclient-side) does not define them, so feat: agent catalog endpoints with on-chain indexing #17's related ACs should be amended
rather than this PR changing the app.
docs/architecture/api.mdrecords thedeferral.
AgentRecord(nullable catalog fields) ratherthan the domain
AgentRepository, because the registry allows a registeredagent with no wallet yet, and the domain
Agentrequires a wallet plus has nomodel/registryId.previous migration predates them.
create-migrationreports no pendingchanges, so the chain is consistent.
PULS3_INDEXER_ENABLED=trueto run it.