Skip to content

Commit b60cff4

Browse files
committed
fix(storage): don't row-cap reads that cannot be paged
The guard layer applied LimitIterator to QueryValidations, QueryTokenRequests and IteratorConfigurations. None of those three accepts a pagination argument, so the error they raised once the cap was exceeded ("must paginate") asked for something the API cannot do. IteratorConfigurations made this a functional regression rather than just misleading advice: LocalMembership.storedIdentityConfigurations drains it in full via collections.ReadAll on the identity Load path, so a node holding more than maxPageSize (default 1000) stored identity configurations of one type failed to load identities, recoverable only by raising or disabling the limit globally. Remove the cap from all three and record at each site why the read is not bounded here, matching the reasoning already applied to the token-store iterators. Row-capping these reads needs a SQL-level LIMIT or paging in their signatures, which the docs now track as follow-up. LimitIterator has no remaining callers, so it and its tests are removed rather than left as dead code. maxPageSize continues to bound QueryTransactions on the owner and audit transaction stores, which does take a pagination argument, so rejecting an unbounded page there is actionable. Write payload limits are unchanged. Also correct the docs, which claimed the token store's unspent/spendable/unsupported iterators were row-capped while guard/token.go explicitly did not cap them and overrode no read method. Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent 97de92f commit b60cff4

7 files changed

Lines changed: 54 additions & 181 deletions

File tree

docs/configuration.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -720,18 +720,22 @@ token:
720720

721721
### Optional: token.storage.maxPageSize
722722

723-
Maximum number of rows a single storage-service read may return. It bounds the pages
724-
accepted by paginated queries and caps streaming iterator reads so an unlimited scan
725-
cannot exhaust database resources. When the key is absent, the default (1000) applies.
723+
Maximum page size a paginated storage-service read may request, so an unlimited scan
724+
cannot exhaust database resources. A query that asks for an unbounded page (`nil` or
725+
`pagination.None()`) or a page larger than this value is rejected; callers page
726+
through the full result set instead. When the key is absent, the default (1000)
727+
applies.
726728

727729
```yaml
728730
token:
729731
storage:
730732
maxPageSize: 1000
731733
```
732734

733-
See [Storage API Limits](services/storage.md#storage-api-limits) for details,
734-
including why movement/balance queries are intentionally not row-capped.
735+
This bounds the paginated reads only. Streaming iterator reads are deliberately not
736+
row-capped, because they accept no page size for the caller to comply with — see
737+
[Storage API Limits](services/storage.md#storage-api-limits) for the full list and
738+
the reasoning, including why movement/balance queries are never row-capped.
735739

736740
---
737741

docs/services/storage.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -367,25 +367,38 @@ writes include the transaction store's `AddTokenRequest` / `AddTransaction` /
367367
`RegisterIdentityDescriptor` / `AddConfiguration`, and the wallet store's
368368
`StoreIdentity`.
369369

370-
**Read size limits.**
371-
- Paginated reads (`QueryTransactions`) require a bounded page: `nil` and
372-
`pagination.None()` are rejected, and a page size larger than `maxPageSize`
373-
(default 1000) is rejected. Callers page through the full result set.
374-
- Streaming iterator reads are capped at `maxPageSize` rows via a limiting iterator
375-
that errors (rather than silently truncating) once the cap is exceeded. Covered
376-
reads include the token store's unspent/spendable/unsupported iterators, the
377-
endorser store's `QueryValidations`, the transaction store's `QueryTokenRequests`,
378-
and the identity store's `IteratorConfigurations`.
379-
- `QueryMovements` is left uncapped on purpose: it feeds balance totals, and dropping
380-
rows there would quietly return wrong balances. It is already narrowed by its
370+
**Read size limit.** `maxPageSize` (default 1000) bounds the paginated reads —
371+
currently `QueryTransactions` on the owner and audit transaction stores. Those
372+
reads require a bounded page: `nil` and `pagination.None()` are rejected, as is a
373+
page size larger than `maxPageSize`. Callers page through the full result set.
374+
375+
Rejecting the request is only a safe way to bound a read when the caller has a way
376+
to comply, so the cap is applied exactly to the reads that accept a pagination
377+
argument. Streaming iterator reads are **not** row-capped:
378+
379+
- The token store's unspent/spendable/unsupported iterators, the endorser store's
380+
`QueryValidations`, the transaction store's `QueryTokenRequests`, and the identity
381+
store's `IteratorConfigurations` all take no page size or cursor, and their
382+
consumers drain them in full — the selectors and integrity checks for the token
383+
iterators, and `LocalMembership.storedIdentityConfigurations` on the identity
384+
`Load` path for `IteratorConfigurations`. Capping any of them would turn a large
385+
but legitimate dataset into a hard failure that the caller cannot page around.
386+
- `QueryMovements` is uncapped for a second reason: it feeds balance totals, so
387+
dropping rows would quietly return wrong balances. It is already narrowed by its
381388
required filters.
382389

383390
You can override the limits in configuration (`token.storage.maxPayloadSize` /
384391
`token.storage.maxPageSize`) — see the [Configuration Guide](../configuration.md).
385392

386-
**Not yet covered (tracked follow-up).** Reads that materialise a full slice/map
387-
before returning (e.g. `ListUnspentTokens`, `QueryTokenDetails`,
388-
`ConfigurationsByID`, `GetWalletIDs`) cannot be bounded by a wrapper alone — the SQL
389-
has already loaded everything — so they need a SQL-level `LIMIT` or conversion to
390-
iterators. The opaque `Keystore.Put` value and input-size caps for variadic id lists
393+
**Not yet covered (tracked follow-up).** Two groups of reads cannot be bounded by a
394+
wrapper at all, and need query-level work instead:
395+
396+
- The streaming iterators listed above. Bounding them requires a SQL-level `LIMIT`
397+
on the query (with a defined order and documented truncation) or adding paging to
398+
their signatures, so the caller can ask for the next page rather than fail.
399+
- Reads that materialise a full slice or map before returning (e.g.
400+
`ListUnspentTokens`, `QueryTokenDetails`, `ConfigurationsByID`, `GetWalletIDs`) —
401+
the SQL has already loaded everything by the time the decorator sees the result.
402+
403+
The opaque `Keystore.Put` value and input-size caps for variadic id lists
391404
(`DeleteTokens`, `GetTokens`) are in the same follow-up.

token/services/storage/db/guard/endorser.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,10 @@ type guardedEndorserStore struct {
2727
policy Policy
2828
}
2929

30-
func (g *guardedEndorserStore) QueryValidations(ctx context.Context, params driver.QueryValidationRecordsParams) (driver.ValidationRecordsIterator, error) {
31-
it, err := g.EndorserStore.QueryValidations(ctx, params)
32-
if err != nil {
33-
return nil, err
34-
}
35-
36-
return LimitIterator(it, g.policy.MaxPageSize, "QueryValidations"), nil
37-
}
30+
// QueryValidations is intentionally not row-capped and delegates to the embedded
31+
// store unchanged: QueryValidationRecordsParams carries no page-size or cursor,
32+
// so a caller that hits a cap has no way to page around it. Bounding this read
33+
// needs a SQL-level LIMIT on the query itself (tracked follow-up), not a wrapper.
3834

3935
func (g *guardedEndorserStore) NewEndorserStoreTransaction() (driver.EndorserStoreTransaction, error) {
4036
w, err := g.EndorserStore.NewEndorserStoreTransaction()

token/services/storage/db/guard/identity.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,12 @@ type guardedIdentityStore struct {
2828
policy Policy
2929
}
3030

31-
func (g *guardedIdentityStore) IteratorConfigurations(ctx context.Context, configurationType string) (idriver.IdentityConfigurationIterator, error) {
32-
it, err := g.IdentityStore.IteratorConfigurations(ctx, configurationType)
33-
if err != nil {
34-
return nil, err
35-
}
36-
37-
return LimitIterator(it, g.policy.MaxPageSize, "IteratorConfigurations"), nil
38-
}
31+
// IteratorConfigurations is intentionally not row-capped and delegates to the
32+
// embedded store unchanged. It takes no pagination argument, and
33+
// LocalMembership.storedIdentityConfigurations drains it in full on the identity
34+
// Load path, so a cap here would turn "this node has many registered identities"
35+
// into a hard identity-loading failure with no way for the caller to page around
36+
// it. Bounding this read needs a SQL-level LIMIT (tracked follow-up).
3937

4038
func (g *guardedIdentityStore) AddConfiguration(ctx context.Context, wp idriver.IdentityConfiguration) error {
4139
size := len(wp.ID) + len(wp.Type) + len(wp.URL) + len(wp.Config) + len(wp.Raw)

token/services/storage/db/guard/iterator.go

Lines changed: 0 additions & 51 deletions
This file was deleted.

token/services/storage/db/guard/iterator_test.go

Lines changed: 0 additions & 76 deletions
This file was deleted.

token/services/storage/db/guard/transactions.go

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,12 @@ func (g *guardedOwnerTx) QueryTransactions(ctx context.Context, params driver.Qu
4848
return g.TokenTransactionStore.QueryTransactions(ctx, params, p)
4949
}
5050

51-
func (g *guardedOwnerTx) QueryTokenRequests(ctx context.Context, params driver.QueryTokenRequestsParams) (driver.TokenRequestIterator, error) {
52-
it, err := g.TokenTransactionStore.QueryTokenRequests(ctx, params)
53-
if err != nil {
54-
return nil, err
55-
}
56-
57-
return LimitIterator(it, g.policy.MaxPageSize, "QueryTokenRequests"), nil
58-
}
51+
// QueryTokenRequests is intentionally not row-capped on either transaction store
52+
// and delegates to the embedded store unchanged: QueryTokenRequestsParams carries
53+
// only a status filter, with no page size or cursor, so a caller that hits a cap
54+
// cannot page around it. Bounding this read needs a SQL-level LIMIT on the query
55+
// itself (tracked follow-up). QueryTransactions above is capped instead, because
56+
// it does take a pagination argument, so rejecting an unbounded page is actionable.
5957

6058
func (g *guardedOwnerTx) AddTransactionEndorsementAck(ctx context.Context, txID string, endorser token.Identity, sigma []byte) error {
6159
if err := CheckPayload("AddTransactionEndorsementAck", len(txID)+len(endorser)+len(sigma), g.policy.MaxPayloadSize); err != nil {
@@ -87,15 +85,6 @@ func (g *guardedAuditTx) QueryTransactions(ctx context.Context, params driver.Qu
8785
return g.AuditTransactionStore.QueryTransactions(ctx, params, p)
8886
}
8987

90-
func (g *guardedAuditTx) QueryTokenRequests(ctx context.Context, params driver.QueryTokenRequestsParams) (driver.TokenRequestIterator, error) {
91-
it, err := g.AuditTransactionStore.QueryTokenRequests(ctx, params)
92-
if err != nil {
93-
return nil, err
94-
}
95-
96-
return LimitIterator(it, g.policy.MaxPageSize, "QueryTokenRequests"), nil
97-
}
98-
9988
func (g *guardedAuditTx) NewTransactionStoreTransaction() (driver.TransactionStoreTransaction, error) {
10089
w, err := g.AuditTransactionStore.NewTransactionStoreTransaction()
10190
if err != nil {

0 commit comments

Comments
 (0)