hyperledger / hyperledger/fabric-x-sdk
Consistent live-state reads via the committer's QueryService
- 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