hyperledger / hyperledger/fabric-x-sdk

Consistent live-state reads via the committer's QueryService

Open
#47 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
3
Forks
5
Avg merge
2d 11h
Merged PRs (30d)
9

Description

## Context

`committerpb.QueryServiceClient` is entirely unused in the SDK today (same gap
[hyperledger/fabric-x-sdk#43](https://github.com/hyperledger/fabric-x-sdk/issues/43) closes for
`GetConfigTransaction`). Two of its RPCs matter for a different reason: `GetRows` reads rows for a
batch of namespace/key requests in one call; `BeginView`/`EndView` bracket those reads in a
server-side, isolation-level-configurable transaction (`ViewParameters.IsoLevel`: `SERIALIZABLE`
default, `REPEATABLE_READ`, `READ_COMMITTED`, `READ_UNCOMMITTED` — these map directly to real
`pgx.TxOptions` on a read-only Postgres transaction, confirmed in the committer source).

This matters for a specific correctness gap, not just convenience. Fabric/Fabric-X MVCC validates
each key's version independently at commit time — reading two keys at two different moments, with an
unrelated commit landing in between, doesn't make either individual read invalid later. But it can
make an endorser's *decision* wrong: check balance A ≥ 10 (true), check B isn't frozen (true), where
an intervening commit already spent A by the time B was read — each read is individually correct and
will validate fine, but the decision was based on values that never coexisted. That's read skew, and
it's exactly what `BeginView`'s isolation levels exist to prevent across multiple `GetRows` calls.
Without a view, `GetRows` runs against the committer's `nonConsistentBatcher` (confirmed in source)
— each call may observe an independently-consistent snapshot, not a shared one.

The SDK already has a way to get a consistent multi-key view locally: `state.VersionedDB.Get(ctx,
ns, key, lastBlock)` gives the same result across calls as long as the caller pins `lastBlock` once
and reuses it, which is the normal, simple way to use it. This is a different, complementary tool:
always live (not lagging behind a local sync), with consistency enforced server-side rather than by
convention — at the cost of a live network dependency and round trips per read, which the local-sync
path doesn't have. Confirmed by reading the committer's implementation directly that a view nobody
explicitly ends isn't a leak: `BeginView` caps the timeout server-side and ties the view to a
`context.WithTimeout`-derived context that auto-cleans up (releases the `MaxActiveViews` slot,
closes the underlying transaction) whether `EndView` is called or the deadline just elapses.
`EndView` only matters for releasing the slot *promptly*.

## Approach

New file `network/fabricx/query.go`, wrapping `committerpb.QueryServiceClient` on the existing
`Peer` type (same pattern as `BlockHeight`/`ConfigTransaction`):

```go
// IsoLevel is the isolation level for a View. The zero value (Unspecified) defaults to Serializable,
// matching committerpb's own default.
type IsoLevel int32

const (
IsoLevelUnspecified IsoLevel = iota
IsoLevelSerializable
IsoLevelRepeatableRead
IsoLevelReadCommitted
IsoLevelReadUncommitted
)

// ViewOptions configures a View's isolation and lifetime.
type ViewOptions struct {
IsoLevel IsoLevel
NonDeferrable bool
// Timeout bounds the view's lifetime; zero uses the committer's configured maximum.
Timeout time.Duration
}

// NamespaceKeys requests specific keys within one namespace.
type NamespaceKeys struct {
Namespace string
Keys [][]byte
}

// Row is a single key/value/version read from live committer state.
type Row struct {
Key []byte
Value []byte
Version uint64
}

// NamespaceRows is the result of a NamespaceKeys query.
type NamespaceRows struct {
Namespace string
Rows []Row
}

// GetRows performs a one-shot read with no explicit view. Each call may observe an independently
// consistent snapshot — use BeginView when multiple reads need to be mutually consistent.
func (p *Peer) GetRows(ctx context.Context, req ...NamespaceKeys) ([]NamespaceRows, error)

// BeginView opens a consistent, read-only snapshot of ledger state. Callers should Close it
// (typically via defer) once done, though the committer auto-expires it after opts.Timeout
// regardless — Close just releases the server-side slot promptly instead of waiting it out.
func (p *Peer) BeginView(ctx context.Context, opts ViewOptions) (*View, error)

// View is a consistent snapshot opened with BeginView. Multiple GetRows/TransactionStatus calls
// against the same View observe one point-in-time state, at the configured isolation level.
type View struct { /* peer *Peer; id string */ }

func (v *View) GetRows(ctx context.Context, req ...NamespaceKeys) ([]NamespaceRows, error)
func (v *View) TransactionStatus(ctx context.Context, txIDs ...string) ([]TxStatus, error)
func (v *View) Close(ctx context.Context) error
```

`Peer.GetRows` and `View.GetRows` share one internal implementation — the only difference is
whether `Query.View` is set on the request. `TxStatusQuery` also takes an optional `view`
(`api/committerpb/query.proto`), so `View.TransactionStatus` reuses the same view ID for free —
included here since it's essentially zero marginal cost once `View` exists, not because it was the
original ask.

**Clean types, not raw protos — unlike `blocks.Block.ConfigEnvelope`.** `Row`/`NamespaceRows` are
plain SDK types, not `committerpb.Row`/`committerpb.Rows`. This is the opposite call from the
CONFIG-envelope exception
([hyperledger/fabric-x-sdk#46](https://github.com/hyperledger/fabric-x-sdk/issues/46)) deliberately:
that case exposed a raw proto because decoding it needs machinery (`channelconfig`/`msp`) that lives
in a different package by design. Key/value/version data has no such complexity — it's the same
shape as `blocks.WriteRecord` already uses — so there's no justification for leaking the wire type
here.

**`Close`'s error handling.** `EndView` returns `FailedPrecondition` if the view is already gone
(e.g. already auto-expired). `View.Close` treats that as success, not an error — the caller's goal
("the view is gone") is already achieved either way, matching how double-`Close` is commonly
tolerated in Go.

**`Close(ctx)`, not `io.Closer`.** `EndView` is itself a gRPC call and needs a context; taking one
explicitly is consistent with the ctx-threading work in `context-in-state-reads.md` at the cost of
not satisfying stdlib's `io.Closer`. Deliberate, not an oversight.

**Considered and rejected: making `View` satisfy `state.ReadStore`/`blocks.RecordGetter`.** Those
interfaces read `Get(ctx, ns, key, lastBlock uint64)` — indexed by block height, i.e. time-travel to
a specific point in chain history. A `View` is a point-in-time snapshot as of when `BeginView` was
called — "now," not "as of block N." Forcing `View` into that interface would mean either silently
ignoring the caller's `lastBlock` or being unable to honor it at all, which is misleading either
way. `View` gets its own API shape instead; `state.VersionedDB`/`SimulationStore`'s block-indexed
read model stays what it is.

**Fabric-X only.** No `network/fabric` counterpart — classic Fabric has no committer-hosted
multi-namespace transactional query service to wrap; reads there go through chaincode (`GetState`)
or peer-side rich queries, a different mechanism entirely. Matches the precedent already
established in [hyperledger/fabric-x-sdk#43](https://github.com/hyperledger/fabric-x-sdk/issues/43)'s
"Classic Fabric support" out-of-scope note.

**`RESOURCE_EXHAUSTED` on `BeginView`.** The committer enforces `MaxActiveViews` and returns a
`RESOURCE_EXHAUSTED` gRPC status when exceeded. Not proposing new SDK-wide error-classification
machinery for this (nothing else in the SDK does that) — just documenting on `BeginView`'s doc
comment that this is a real, expected, retryable condition, and letting callers use
`status.Code(err)` themselves if they want to distinguish it.

## Out of scope

- `GetNamespacePolicies` — same `QueryServiceClient`, unrelated concept, not needed for this.
- Testing against `fabrictest`: it doesn't implement `committerpb.QueryServiceServer` at all yet.
Wiring that up belongs to
[hyperledger/fabric-x-sdk#45](https://github.com/hyperledger/fabric-x-sdk/issues/45) (which
already flags this same gap for `GetConfigTransaction`); until then, this can only be verified
against a real committer (`make start-x`), same as `ConfigTransaction`.
- Any attempt to unify `View`'s read model with `state.VersionedDB`'s block-indexed one (see
"considered and rejected" above) — including a possible future `SimulationStore` variant backed
by a live `View` instead of a local sync. Worth thinking about later, not designed here.

## Verification

- `go build ./...` / `go vet ./...`.
- A new automated test in `integration/`, alongside `TestFabricXCommitter` (same
`testing.Short()` skip, same `newTestCommitterSetup(t)`/`make start-x` precondition — not a manual
step): write key K = "v1" and confirm it with the existing `waitForKeyValue` helper
(integration_test.go:414, which polls the local synced DB — a real synchronization point, not a
sleep); `BeginView` with `REPEATABLE_READ`; `GetRows` for K within the view, assert "v1"; submit a
second write K = "v2" and confirm it with `waitForKeyValue` again; `GetRows` for K within the
*same* view again, assert it's still "v1" (proving isolation from a commit that's already
confirmed to have landed); `Close` the view; a final `GetRows` with no view, assert "v2". Exercises
the actual isolation guarantee end to end, deterministically, not just that the RPCs are wired up.
- `make checks`.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the existing BlockHeight and ConfigTransaction wrappers in network/fabricx, then read api/committerpb/query.proto and the proposed integration point in network/fabricx/query.go. Run the integration tests with make start-x and use integration_test.go's waitForKeyValue helper; done means go build ./..., go vet ./..., make checks, and the repeatable-read test observes v1 inside the view and v2 after Close.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, grpc, postgresql
Domain
api, backend, testing
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.