feat: nest account-scoped operations & state changes on Account.transactions#620
feat: nest account-scoped operations & state changes on Account.transactions#620aditya1702 wants to merge 21 commits into
Conversation
…unt-scoped per-to_id LATERAL)
…ions/stateChanges edge
…s for nested edge partition pinning
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
There was a problem hiding this comment.
Pull request overview
This PR updates the GraphQL API so accountByAddress { transactions { edges { node operations stateChanges } } } returns account-scoped operations and state changes per transaction edge in a single request, backed by partition-pinned, account-scoped DB queries and new dataloaders, plus an SDK method to consume the new shape.
Changes:
- GraphQL:
Account.transactionsnow returnsDetailedTransactionConnection, with edge-level lazy resolvers foroperationsandstateChanges. - Data + dataloaders: add account-scoped batch loaders and data-model methods using
UNNEST(...)+LATERALqueries pinned byledger_created_at. - SDK: add
GetAccountTransactionsWithDetailsand client-side types/unmarshal logic; add tests for scoping and partition-pin regression.
Reviewed changes
Copilot reviewed 17 out of 23 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/wbclient/types/types.go | Adds SDK connection/edge types + custom JSON unmarshaling for detailed transaction edges. |
| pkg/wbclient/queries.go | Adds nested GraphQL query builder for transactions with embedded operations/state changes. |
| pkg/wbclient/client.go | Adds GetAccountTransactionsWithDetails client method. |
| pkg/wbclient/client_test.go | Adds SDK tests for detailed transaction query + deserialization behaviors. |
| internal/serve/graphql/schema/pagination.graphqls | Introduces DetailedTransactionConnection/DetailedTransactionEdge schema types. |
| internal/serve/graphql/schema/account.graphqls | Switches Account.transactions to return DetailedTransactionConnection. |
| internal/serve/graphql/resolvers/account.resolvers.go | Returns detailed edges and stamps account context onto edges for nested resolvers. |
| internal/serve/graphql/resolvers/pagination.resolvers.go | Implements edge resolvers that load account-scoped operations/state changes via dataloaders. |
| internal/serve/graphql/resolvers/pagination_resolvers_test.go | Adds resolver-level tests for account scoping and partition pinning. |
| internal/serve/graphql/dataloaders/loaders.go | Registers new account-scoped loaders on the dataloader bundle. |
| internal/serve/graphql/dataloaders/operation_loaders.go | Adds account-scoped operations-by-transaction loader. |
| internal/serve/graphql/dataloaders/statechange_loaders.go | Adds account-scoped statechanges-by-transaction loader. |
| internal/data/transactions.go | Ensures ledger_created_at is always selected for partition pinning downstream. |
| internal/data/operations.go | Adds BatchGetAccountOperationsByToIDs using partition-pinned UNNEST + LATERAL. |
| internal/data/operations_test.go | Adds coverage for account-scoped operations-by-transaction batch query. |
| internal/data/statechanges.go | Adds BatchGetAccountStateChangesByToIDs using partition-pinned UNNEST + LATERAL. |
| internal/data/statechanges_test.go | Adds coverage for account-scoped statechanges-by-transaction batch query. |
| internal/indexer/types/types.go | Adds backing Go struct for DetailedTransactionEdge (includes non-schema account context). |
| internal/serve/graphql/generated/models_gen.go | Adds generated model for DetailedTransactionConnection. |
| internal/serve/graphql/generated/generated.go | Regenerates gqlgen output for new schema types/resolvers. |
| internal/serve/graphql/resolvers/statechange.resolvers.go | Minor generated formatting changes (type declarations). |
| internal/serve/graphql/resolvers/queries.resolvers.go | Import reordering from codegen/tidy. |
| gqlgen.yml | Maps DetailedTransactionEdge schema type to its Go model. |
Files not reviewed (4)
- internal/serve/graphql/resolvers/account.resolvers.go: Language not supported
- internal/serve/graphql/resolvers/pagination.resolvers.go: Language not supported
- internal/serve/graphql/resolvers/queries.resolvers.go: Language not supported
- internal/serve/graphql/resolvers/statechange.resolvers.go: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- dataloaders: group account-scoped operation/state-change loaders by
account (newAccountScopedLoader) so a single aliased multi-account request
cannot cross-contaminate edges that share a transaction
- wbclient: enforce non-null operations/stateChanges when unmarshaling
DetailedTransactionEdge, matching the schema and the node/edges/pageInfo checks
- data: guard BatchGetAccount{Operations,StateChanges}ByToIDs against
mismatched parallel-array lengths
…ountTransaction{Connection,Edge}
…countTransactionsWithOpsAndStateChanges
gqlgen emits import blocks that goimports -local regroups (separating the wallet-backend prefix into its own block). CI's goimports step checks these files, so the generated.go and *.resolvers.go output must be run through goimports after generation.
gqlgen emits import blocks that goimports -local regroups, so regenerating would silently revert the goimports-compliant form that CI's goimports gate requires. Running goimports -w as part of gql-generate makes the target self-normalizing, so regeneration order no longer determines compliance.
…rve mode Latency testing in dev found the API OOM-killed at its memory limit after ~2.7h of sustained nested account-history load, with GC pressure degrading latency beforehand — and no way to see it coming: the serve process exposed no go_* runtime metrics and had no pprof endpoint (only ingest did). - metrics.NewMetrics now registers the Go runtime and process collectors, covering both serve and ingest registries (TDD'd). - serve gains the same optional admin pprof server as ingest (admin-port / ADMIN_PORT, default off). Dev already sets ADMIN_PORT=9095, so heap profiling lights up on deploy with no config change.
… statement cache thrash prepareColumnsWithID round-tripped the column list through map-backed sets, so the SELECT column order rotated randomly on every call. The order is part of the SQL text, which keys two caches: - pgx's global RowToStructByName field-mapping cache (sync.Map, no eviction): every fresh ordering created a permanent entry. Under sustained load the serve process leaked ~1.5MiB/min until OOM (heap-diff confirmed: 74% of growth under pgx.lookupNamedStructFields). Restarts reset it; latency crept up beforehand from GC pressure (p99 70ms -> 1.8s over ~17h). - the per-connection prepared statement cache: near-unique SQL per request meant Parse/Describe round trips on almost every query, and useless pg_stat_statements (thousands of calls=1 variants). Sorting makes the SQL text deterministic per column set: one pgx cache entry and one prepared statement per query shape, reused forever.
The UNNEST + CROSS JOIN LATERAL shape is a joinable subquery that the planner may flatten into a hash join scanning the account's entire state_changes history across all chunks. On compressed chunks (no b-tree indexes) that scan replaces ~100 index probes with a multi-million-row read per request. A flat WHERE with to_id = ANY(...) plus a ledger_created_at range has no join to flatten: compressed chunks are read via the segmentby index plus batch min/max metadata (one batch per page window in the common case), uncompressed chunks via ordinary index scans, and out-of-range chunks are excluded entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A subquery containing LIMIT cannot be flattened into a join, so the planner must execute the operations_accounts band lookup as a per-pair index probe instead of scanning the account's entire history and hash-joining the pairs. LIMIT 4096 is an invariant, not tuning: a TOID reserves 12 bits for the operation index, so the band predicate already bounds the subquery below 4096 rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-bearing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes #619
TL;DR
accountByAddress.transactionsnow embeds each transaction's account-scopedoperationsandstateChangeson the edge — a full account history in one GraphQL call instead of N follow-up requests.GetAccountTransactionsWithOpsAndStateChangesmethod + response types, with strict (non-null-enforcing) JSON decoding.account.transactions/operations/stateChangesnow capfirst/lastat 100 (returningBAD_USER_INPUTabove that), mirroringaccount.balances— the nested per-transaction fan-out makes unbounded pages expensive.node,cursor, andpageInfoare unchanged; callers that don't request the new fields are unaffected.BatchGetByAccountAddressnow always selectsledger_created_at(the partition key); without it, a query omittingledgerCreatedAton the node would have silently returned empty nested lists. Guarded by a regression test.Summary (layer by layer)
BatchGetAccountOperationsByToIDs/BatchGetAccountStateChangesByToIDs: per-to_idLATERALqueries that pinledger_created_at(the TimescaleDB partition column) for chunk exclusion, scope byaccount_id, and return the complete per-transaction set (noLIMIT), ordered DESC. Operations have no account column, so they're scoped throughoperations_accountsvia the TOID band; state changes scope directly via theiraccount_idcolumn.accountOperationsByToIDLoader/accountStateChangesByToIDLoaderwrap those methods, batching all of a page's transactions into one query keyed by transactionto_id.Account.transactionsnow returnsAccountTransactionConnection; itsAccountTransactionEdgecarriesoperations/stateChanges(both@goField(forceResolver: true)so they resolve lazily through the loaders). The Go backing struct threads the account address from the parentAccountresolver down to the edge resolvers — it scopes the lookups but is not part of the public schema. Page size ontransactions/operations/stateChangesis capped at 100 viaparseAccountPaginationParams.GetAccountTransactionsWithOpsAndStateChangesissues the nested query; new connection/edge types whoseUnmarshalJSONenforces the schema's non-null contract (edges,node,pageInfo) and dispatches polymorphic state-change nodes by__typename.BatchGetByAccountAddressalways selectsledger_created_atso the nested edge resolvers receive a real ledger time to pin (see TL;DR).Test Plan
make checkclean — fmt, vet, lint, shadow, exhaustive, deadcode, goimports, govulncheck (+tidyapplied)go test ./internal/data/... ./internal/serve/graphql/... ./pkg/wbclient/...→ 499 passedledgerCreatedAtis omitted from the node selection (load-bearing — fails if the fix is reverted)parseAccountPaginationParamsrejectsfirst/last> 100 withBAD_USER_INPUT🤖 Generated with Claude Code