Skip to content

feat(graphql): add the paginated transactionsByDigests query - #12684

Merged
tomxey merged 16 commits into
developfrom
sc-platform/graphql-tx-by-digests-paginated
Aug 19, 2026
Merged

feat(graphql): add the paginated transactionsByDigests query#12684
tomxey merged 16 commits into
developfrom
sc-platform/graphql-tx-by-digests-paginated

Conversation

@tomxey

@tomxey tomxey commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description of change

Adds a new transactionsByDigests query that returns transactions in pages. Each page holds one entry per digest, in the order of the digests argument, null when the transaction was not found — the same shape as transactionBlocksByDigests, just chunked. Like the old query, it includes transactions that are not checkpointed yet.

Pagination is forward-only: limit caps the page size and cursor resumes after an entry of a previous page. The result is a plain page type (nodes, hasNextPage, endCursor), not a connection. The cursor also pins the checkpoint the first page was viewed at, so later pages keep the same view.

The existing transactionBlocksByDigests query is unchanged and is now deprecated, to be removed in v1.38. This keeps the change non-breaking.

Links to any relevant issues

fixes #9156

Removal of the deprecated transactionBlocksByDigests is tracked in #12688.

How the change has been tested

  • New graphql-e2e test crates/iota-graphql-e2e-tests/tests/transactions/by_digests_paginated.move: input order, a null entry for a digest that is not found, limit pages, cursor resume, a full page of nulls, and an empty digest list.

  • New e2e test test_transactions_by_digests: the query returns transactions right after execution (before they are checkpointed), in input order, with null for a missing digest.

  • Basic tests (linting, compilation, formatting, unit/integration tests)

  • Patch-specific tests (correctness, functionality coverage)

Infrastructure QA (only required for crates that are maintained by @iotaledger/infrastructure)

  • Synchronization of the indexer from genesis for a network including migration objects.
  • Restart of indexer synchronization locally without resetting the database.
  • Restart of indexer synchronization on a production-like database.
  • Deployment of services using Docker.
  • Verification of API backward compatibility.

Release Notes

  • GraphQL: added a transactionsByDigests query that returns transactions in pages, one entry per requested digest in input order (null when not found), with forward-only cursor/limit pagination. It includes both old transactions from fallback and fresh transactions just executed via GraphQL. transactionBlocksByDigests is deprecated and will be removed in v1.38.

@tomxey
tomxey requested a review from a team as a code owner August 17, 2026 11:48
@iota-ci iota-ci added infrastructure Issues related to the Infrastructure Team sc-platform Issues related to the Smart Contract Platform group. labels Aug 17, 2026
@tomxey
tomxey force-pushed the sc-platform/graphql-tx-by-digests-paginated branch from 6127d7d to a570323 Compare August 17, 2026 11:54
@tomxey tomxey self-assigned this Aug 17, 2026
@tomxey tomxey changed the title feat(graphql): paginate transactionBlocksByDigests feat(graphql): add the paginated transactionsByDigests query Aug 17, 2026
Comment thread crates/iota-graphql-rpc/src/types/transaction_block/mod.rs Outdated
Comment thread crates/iota-graphql-rpc/src/types/transaction_block/mod.rs Outdated
Comment thread crates/iota-graphql-rpc/src/types/query.rs Outdated
@tomxey
tomxey requested a review from kodemartin August 18, 2026 12:44

@kodemartin kodemartin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm 🚢

An error occurred while trying to automatically change base from sc-platform/graphql-fallback-docs to sc-platform/graphql-fix-checkpoint-paginate-doc August 19, 2026 06:36
tomxey added 16 commits August 19, 2026 09:06
Add CLI/env options to configure KV client in graphql, analogous to how
it is configured in iota-indexer.

Constructed client is attached to the Indexer Reader, to be used for
actual fallback that will be introduced in subsequent PRs.

fixes #11932

Describe the tests that you ran to verify your changes.

Make sure to provide instructions for the maintainer as well as any
relevant configurations.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [ ] Patch-specific tests (correctness, functionality coverage)

@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.

- [x] GraphQL: new optional CLI flags `--fallback-kv-url`,
`--fallback-kv-multi-fetch-batch-size`,
`--fallback-kv-concurrent-fetches`, `--fallback-kv-cache-size` for the
archival fallback. Setting `--fallback-kv-url` enables fallback to KV
store for data pruned from postgres DB.
Makes the GraphQL `Query.checkpoint(id)` and `Query.checkpoints(...)`
resolvers fall back to the historical fallback storage when reading
checkpoints whose rows have been pruned from Postgres.

- **`IndexerReader::get_stored_checkpoints_by_seqs_with_fallback`** —
reads checkpoints by their sequence numbers from Postgres, falls back to
historical fallback storage if needed.
- **`IndexerReader::get_stored_checkpoints_by_digests_with_fallback`** —
same as above but with "digest" key.

-------

- **`DBReader::multi_get_checkpoints_by_seqs`** — internal helper; runs
one Diesel query to load checkpoint rows for a list of sequence numbers.
- **`DBReader::multi_get_checkpoints_by_digests`** — internal helper;
runs one Diesel query to load checkpoint rows for a list of digests.

----------

- **`HistoricalFallbackReader::checkpoints_by_digests`** — reads a batch
of checkpoints from the historical fallback storage by their digests
(analogous to already existing `checkpoints` function)

fixes #11934

Describe the tests that you ran to verify your changes.

Make sure to provide instructions for the maintainer as well as any
relevant configurations.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

**Setup:** 10 checkpoints across 4 epochs (epoch 0: cps 0–2, epoch 1:
cps 3–4, epoch 2: cps 5–6, epoch 3: cps 7–9). With `--epochs-to-keep 1`
and `--wait-for-checkpoint-pruned 6`, cps 0–6 are pruned and cps 7–9
remain. No historical fallback configured.

| # | Input | Expected |
  |---|---|---|
| **A** | `checkpoints { ... }` (defaults) | nodes `[7, 8, 9]`,
`hasPreviousPage = false`, `hasNextPage = false` — the reachable range
is `[7, 9]` and fits in one page |
| **B** | `checkpoints(first: 2) { ... }` | nodes `[7, 8]`,
`hasPreviousPage = false`, `hasNextPage = true` — at the lower bound,
more above |
| **C** | `checkpoints(first: 2, after: cursor{c: 9, s: 2})` | error
`DATA_PRUNED` — cursor's seq is below the pruning watermark |
| **C2** | `checkpoints(first: 2, after: cursor{c: 9, s: 42})` | error
`BAD_USER_INPUT` — cursor's seq is above its own `checkpoint_viewed_at`
(malformed) |
| **D** | `checkpoints(first: 2, after: cursor{c: 9, s: 7})` | nodes
`[8, 9]`, `hasPreviousPage = true`, `hasNextPage = false` — cursor at
the lower boundary is in-range |
| **E** | `checkpoint(id: { sequenceNumber: 0 })` | `null` — pruned, no
fallback to consult |
| **F** | `checkpoint(id: { sequenceNumber: 7 })` | `{ sequenceNumber: 7
}` — unpruned, resolves |
| **G** | `epoch(id: 0) { epochId, checkpoints { ... } }` | `epochId:
0`; `checkpoints` errors `DATA_PRUNED` — epoch 0's range is entirely
below the watermark |
| **H** | `epoch(id: 3) { epochId, checkpoints { ... } }` | `epochId:
3`; nodes `[7, 8, 9]`, both flags `false` — unpruned epoch paginates
normally |

@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.
Adds historical fallback support for GraphQL transaction-by-digest
lookups. The four covered paths:

- `Query.transactionBlock(digest)`
- `Query.transactionBlocksByDigests(digests)`
- `Event.transactionBlock`
- `MoveObject.previousTransactionBlock`

All four go through
`IndexerReader::multi_get_transactions_with_fallback`, the same method
JSON-RPC already uses for transactions by digest.

`multi_get_transactions` and `multi_get_transactions_with_fallback` now
return `Vec<TransactionRead>` - a new enum that tags each row as
`Checkpointed` or `Optimistic`, so the GraphQL loader can build the
right `TransactionBlockInner` variant.

`Event.transactionBlock` switched from a sequence-number lookup to a
digest lookup. A seq-based lookup would not work for events extracted
from fallback as we would not be able to do the seq_num->digest
conversion for already pruned transactions.

fixes #11935

New e2e test:
`crates/iota-graphql-e2e-tests/tests/prune/transaction_block_pruning.move`
covers the four affected paths.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.
# Description of change

Adds historical fallback for GraphQL queries that list the transactions
of a single checkpoint. Covered paths:

- `Query.transactionBlocks(filter: { atCheckpoint: ... })`
- `Checkpoint.transactionBlocks`

Queries with only the `atCheckpoint` filter are served by the new
`IndexerReader::query_stored_transactions_by_checkpoint_seq_with_fallback`
method. A pruned checkpoint is served from the historical storage when
configured, and returns a `DATA_PRUNED` error otherwise (before this
change, the page came back empty). Queries with other filters keep using
the SQL path.

The fallback reader's `checkpoint_transactions` now takes the cursor
either by transaction digest (used by JSON-RPC) or by
`tx_sequence_number` (used by GraphQL, see `CheckpointTxCursor`). The
page is fetched with one extra row on each side, so
`hasPreviousPage`/`hasNextPage` can be filled.

## Links to any relevant issues

fixes #11936

## How the change has been tested

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

### Infrastructure QA (only required for crates that are maintained by
@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.
…phql (#12214)

# Description of change

Add possibility to read txs affected by address in graphql.

This is to match the same ability in JSON RPC, and in the followup issue
to enable serving such queries from the KV fallback.

## Links to any relevant issues

fixes #12213

## How the change has been tested

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

### Infrastructure QA (only required for crates that are maintained by
@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.

### Release Notes

- [x] GraphQL: Added the `affectedAddress` filter on `transactionBlocks`
and the `AFFECTED` relation on `Address.transactionBlocks`. They list
transactions where the address is the sender or a recipient, providing
the same functionality as `FromOrToAddress` filter in the JSON-RPC.
Make GraphQL fall back to the archival store when reading events of a
single transaction.

Covered paths:

- `Query.events(filter: { transactionDigest })` — when this is the only
filter, the query is served by
`IndexerReader::query_stored_events_by_tx_digest_with_fallback`, which
reads from Postgres and falls back to the archival store when the
transaction has been pruned. Uses `Page::paginate_results`.
- `TransactionBlockEffects.events` — worked already, no change needed

Event filters (sender, emitting module, event type) disable the
fallback.

To have common code between JSON-RPC and Graphql the PR refactors some
of DBReader and HistoricalFallbackReader functions to accept only
`event_seq` cursor/range, instead of JSON-RPC or Graphql specific
cursors. (JSON-RPC use (tx_digest, event_seq) cursor, Graphql uses
(tx_seq, event_seq) ranges).
The tx part of the cursor can be stripped away after proper validation,
since we are fetching events from single transaction anyway.

Behavior change: `Query.events(filter: { transactionDigest })` on a
pruned (or unknown) transaction now errors with `DATA_PRUNED` when no
fallback is configured, instead of returning an empty connection. This
matches JSON-RPC.

fixes #11938

- New e2e test
`crates/iota-graphql-e2e-tests/tests/prune/events_by_tx_digest.move`:
`DATA_PRUNED` error on a pruned transaction, events of an unpruned
transaction, cursor window, cursor from a different transaction, digest
combined with sender.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.

- [x] GraphQL: `Query.events(filter: { transactionDigest })` now
supports fallback for pruned transactions.
# Description of change

Make GraphQL fall back to the archival KV store when reading an object
at a version whose row has been pruned from Postgres.

Covered paths:

- `Query.object(address, version)`
- `Query.owner(address, rootVersion)` → `MoveObject.dynamicField` /
`dynamicObjectField` — the field object's ID is derived from the parent
and the field name, and looked up at the latest version `<= rootVersion`

Notes:

- The fallback is wired into the two object DataLoaders (batched KV
multi-gets) through a new `IndexerReader::multi_get_fallback_objects`.
- Both loaders (`HistoricalKey` — an exact version, `ParentVersionKey` —
the largest version `<= parent_version`) resolve the requested version
through `objects_version` and join it with the row content from
`checkpointed_objects` / `objects_backward_history`:
  - an active row is served from Postgres,
- a wrapped or deleted version resolves as non-existent, without
consulting the fallback,
- a version whose row content is outside the retention window of
`objects_backward_history` is fetched from the fallback at the exact
resolved version
- a key with no `objects_version` entry resolves as non-existent when
the table covers the whole chain history, and errors with `DATA_PRUNED`
when it is incomplete
- `ParentVersionKey` loader does not support fallback for versions not
in `objects_version` because KV doesnt store wrapped-or-deleted objects
and therefore `before_version` queries would not return correct version
for this kind of query
- Look-ups that cannot be served return a `DATA_PRUNED` error per key
(instead of failing the whole batch)

## Links to any relevant issues

fixes #11933

## How the change has been tested

- New e2e test
`crates/iota-graphql-e2e-tests/tests/prune/objects_at_version.move`:
pruned versions and a pruned dynamic field error with `DATA_PRUNED` (no
fallback configured); current and never-existing versions resolve as
before.
- Extended `consistency/dynamic_fields/dof_add_reclaim_transfer.move`:
fetching the deleted field wrapper at its exact deletion version
resolves as non-existent.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

### Infrastructure QA (only required for crates that are maintained by
@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.

### Release Notes

- [x] GraphQL: `Query.object(address, version)` and dynamic field
look-ups at a `rootVersion` resolve pruned object versions from the
archival store when the historical fallback is configured; look-ups that
cannot be served return a `DATA_PRUNED` error instead of resolving as
non-existent.
…12553)

# Description of change

Enable GraphQL fallback to the archival KV store when listing
transactions that affect a given address.

Covered paths:

- `Query.transactionBlocks(filter: { affectedAddress })`
- `Address.transactionBlocks` with the `AFFECTED` relation

Notes:

- The query is served by a new
`IndexerReader::query_stored_transactions_by_affected_addresses_with_fallback`,
extracted from the JSON-RPC `FromOrToAddress` fallback.
- Converting a sequence number (used by graphql) back to a digest is not
easily possible, that's why a small refactor of iota-indexer was done to
use tx sequence numbers in the core functions.
- The check rejecting cursors below the pruning watermark now looks at
the first transaction of the page instead of the cursor row (which is
never returned), this also fixes JSON-RPC handling of cursors right at
the watermark.

## Links to any relevant issues

fixes #11937

## How the change has been tested

- New e2e test
`crates/iota-graphql-e2e-tests/tests/prune/transaction_blocks_by_affected_address.move`
(no fallback configured): paginating forward from the pruned earliest
history and from cursors in the pruned range errors with `DATA_PRUNED`;
paginating backward serves the unpruned range; a cursor window starting
exactly at the watermark resolves; the `AFFECTED` relation paginates the
same way.
- The KV-served paths (fast path, whole page from KV, descending top-up
across the watermark, cursor cache) cannot be covered by the e2e tests
and will be manually tested in followup issues.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

### Infrastructure QA (only required for crates that are maintained by
@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.

### Release Notes

- [x] GraphQL: `Query.transactionBlocks(filter: { affectedAddress })`
and `Address.transactionBlocks` with the `AFFECTED` relation resolve
pruned history from the archival store when the historical fallback is
configured, and report `DATA_PRUNED` otherwise.
…12571)

# Description of change

Add Prometheus metrics for the requests the historical fallback client
sends to the archival KV store:

- `historical_fallback_requests` — requests to the KV store, labeled by
`resource` (`tx`, `fx`, `cs`, `cc`, `ob`, `txa`, ...).
- `historical_fallback_request_errors` — requests that failed, with the
same label.

Counted per HTTP request, cache hits are not counted. The counters are
exposed for both indexer JSON-RPC and GraphQL, next to the existing
`historical_fallback_cache_*` metrics.

## Links to any relevant issues

fixes #11940

## How the change has been tested

Manual smoke test against the testnet archival KV store and a pruned
testnet indexer database:

- Queries for pruned checkpoints and transactions by affected address
increment `historical_fallback_requests` for each resource type
involved.
- Repeating the same query is served from the cache and does not
increment the request count.
- With the KV store unreachable, the failing query increments
`historical_fallback_request_errors`.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

### Infrastructure QA (only required for crates that are maintained by
@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.
…ion IDs (#12616)

# Description of change

`Query.transactionBlocks(filter: { transactionIds })` now uses the
fallback KV store. It gets the requested transactions with
`multi_get_transactions_with_fallback`, keeps the ones that are visible
at the viewed checkpoint and inside the cursor range, and returns them
ordered by `tx_sequence_number`.

This makes it work the same way as the parts that already use the
fallback:
- JSON-RPC `iota_multiGetTransactionBlocks` and
`iota_getTransactionBlock`
- the GraphQL `transactionBlock(digest)` query

When `transactionIds` is used together with other filters, it still
reads only from Postgres, same as the other fallback cases.

## Links to any relevant issues

fixes #12589

## How the change has been tested

- New e2e test
`crates/iota-graphql-e2e-tests/tests/prune/transaction_blocks_by_ids.move`,
with no fallback set up: when a pruned digest and an unpruned digest are
asked for together, only the unpruned one comes back, and paging
(forward and backward) works.

- [x] Basic tests (linting, compilation, formatting, unit/integration
tests)
- [x] Patch-specific tests (correctness, functionality coverage)

### Infrastructure QA (only required for crates that are maintained by
@iotaledger/infrastructure)

- [ ] Synchronization of the indexer from genesis for a network
including migration objects.
- [ ] Restart of indexer synchronization locally without resetting the
database.
- [ ] Restart of indexer synchronization on a production-like database.
- [ ] Deployment of services using Docker.
- [ ] Verification of API backward compatibility.

### Release Notes

- [x] GraphQL: `Query.transactionBlocks(filter: { transactionIds })` can
now read pruned transactions from the fallback KV store when it is
configured.
…12617)

# Description of change

The doc comment on `Checkpoint::paginate` said that a cursor or epoch in
the pruned range always returns an error. This is not true anymore: when
the fallback KV store is configured, they are read from it. Updated the
comment to say so.

Doc comment only, no behavior change.

## Links to any relevant issues

fixes #12590
# Description of change

Documents the archival (historic) fallback for `iota-graphql-rpc`:

- **`crates/iota-graphql-rpc/README.md`** — a standalone "Historic
fallback (REST KV store)" section: which queries fall back, how to
enable it (`--fallback-kv-url` and the tuning flags), and the list of
covered queries.
- **`docs/content/operator/extended-data-services/graphql.mdx`**
(operator doc) — a "Pruned data" section mirroring the JSON-RPC operator
doc, the four `--fallback-kv-*` flags added to the settings table, and
the stale dependency note ("fallback service cannot be configured for
GraphQL") replaced.

Both note the one limit: resolving a dynamic field or dynamic object
field at a parent object's version needs that version in
`objects_version`, so on snapshot-restored indexers versions below the
restore point return `DATA_PRUNED`.

Docs only, no code change.

## Links to any relevant issues

fixes #11941
`transactionBlocksByDigests` now returns a cursor-paginated connection,
ordered by transaction digest and including optimistic transactions.
Missing digests are absent from the result instead of returned as null.
`transactionBlocksByDigests` is restored unchanged and deprecated, so the
change is non-breaking. The paginated connection is now the new
`transactionsByDigests` query.
`transactionsByDigests` now returns a page with one entry per digest, in
the order of the `digests` argument, null when the transaction was not
found. Pagination is forward-only: `cursor` resumes after an entry and
`limit` caps the page size.
@tomxey
tomxey changed the base branch from sc-platform/graphql-fallback-docs to infra/feat/graphql-historical-fallback August 19, 2026 07:34
@tomxey
tomxey force-pushed the sc-platform/graphql-tx-by-digests-paginated branch from 5bb7eb4 to 4c72565 Compare August 19, 2026 08:04
@tomxey
tomxey added this pull request to the merge queue Aug 19, 2026
Base automatically changed from infra/feat/graphql-historical-fallback to develop August 19, 2026 11:08
Merged via the queue into develop with commit 737055a Aug 19, 2026
31 checks passed
@tomxey
tomxey deleted the sc-platform/graphql-tx-by-digests-paginated branch August 19, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

infrastructure Issues related to the Infrastructure Team sc-platform Issues related to the Smart Contract Platform group.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(graphql-rpc): Provide paginated response using transaction digests as the cursor for the transactionBlocksByDigests

4 participants