Skip to content

Commit 9bf2d0b

Browse files
committed
fix(identity): make RecipientDataCache provisioning cancellable
Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent a29b5a4 commit 9bf2d0b

16 files changed

Lines changed: 916 additions & 25 deletions

File tree

.golangci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ linters:
106106
- go.opentelemetry.io/otel/trace.Span
107107
- github.com/hyperledger/fabric-lib-go/common/metrics.Gauge
108108
- github.com/hyperledger/fabric-lib-go/common/metrics.Histogram
109+
- github.com/hyperledger-labs/fabric-smart-client/platform/view/services/metrics.Gauge
109110
- github.com/hyperledger-labs/fabric-smart-client/platform/common/driver.ConfigService
110111
- github.com/hyperledger-labs/fabric-smart-client/integration/nwo/api.ViewClient
111112
- github.com/hyperledger-labs/fabric-smart-client/integration/nwo/api.Platform

docs/services/identity.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,74 @@ func (d *Base) NewWalletService(...) (*wallet.Service, error) {
162162

163163
> **Note:** `provider` here is a `NewTMSProvider`-wrapped `Provider` (see [Driver Metrics](../drivers/metrics.md#pitfall-labelnames-must-include-network-channel-namespace)), which binds `network`/`channel`/`namespace` on every metric via `.With(...)` before returning it. Every `CounterOpts`/`HistogramOpts` above must therefore declare those three as `LabelNames` in addition to its own label(s), or the metric panics with "inconsistent label cardinality" on first use. This is exactly the bug that crashed the DVP/DLog integration suite in `SignerRouter.Register` before it was fixed.
164164
165+
## Wallet Lifecycle and Recipient Data Caching
166+
167+
Anonymous owner wallets hand out a fresh pseudonym for every payment. Generating one is
168+
expensive (an Idemix pseudonym plus a registry binding), so `AnonymousOwnerWallet` keeps a
169+
pre-provisioned buffer of recipient data. Two caches implement that buffer:
170+
171+
| Cache | Buffers | Sized by |
172+
|:------|:--------|:---------|
173+
| `role.RecipientDataCache` (`token/services/identity/role/cache.go`) | `driver.RecipientData` (pseudonym + audit info) for one wallet | `wallets.owners[].cacheSize`, falling back to `wallets.defaultCacheSize` (see [configuration](../configuration.md)) |
174+
| `idemix/cache.IdentityCache` (`token/services/identity/idemix/cache/cache.go`) | `idriver.IdentityDescriptor` for one Idemix key manager | same lookup, via `KeyManagerProvider.cacheSizeForID` |
175+
176+
Both follow the same contract:
177+
178+
* **Provisioning is lazy.** The background goroutine is started by the first request, and
179+
only when the configured size is greater than zero. With a size of zero the cache is
180+
disabled and every request goes straight to the backend.
181+
* **Requests never wait on the cache.** A request that does not find a buffered entry
182+
within a short timeout (5 ms) generates the data on the spot instead of blocking, so a
183+
slow backend degrades latency rather than stalling the caller. A cancelled caller
184+
context aborts the request immediately.
185+
* **A failing backend backs off, and is observable.** The provisioning loop logs the
186+
failure, increments a counter and waits one second before retrying, so a broken
187+
identity backend cannot turn pre-provisioning into a busy loop — and the condition can
188+
be alerted on instead of only appearing in the logs.
189+
* **`Close()` is mandatory and idempotent.** It cancels the background context, which
190+
terminates the provisioning goroutine even while it is parked on a full buffer or
191+
inside a retry backoff. A cache that is never closed keeps its goroutine, its channel
192+
and its backend closure alive for the lifetime of the process. After `Close()` the
193+
cache still serves requests from the backend; it simply stops pre-provisioning.
194+
195+
### Cache metrics
196+
197+
| Metric | Type | Cache | Purpose |
198+
|:-------|:-----|:------|:--------|
199+
| `recipient_data_cache_level` | Gauge | `RecipientDataCache` | Entries currently buffered. Counted only once an entry is really in the buffer, so it cannot drift upward when the producer is blocked. |
200+
| `recipient_data_provision_failures_total` | Counter | `RecipientDataCache` | Failed pre-provisioning attempts. A rising rate means the identity backend is failing and requests are falling back to the slower on-demand path. |
201+
| `cache_level` | Gauge | idemix `IdentityCache` | As above, for Idemix identities. |
202+
| `cache_provision_failures_total` | Counter | idemix `IdentityCache` | As above, for Idemix identities. |
203+
204+
> **Note:** these providers are `NewTMSProvider`-wrapped, so every `GaugeOpts`/`CounterOpts`
205+
> above must declare `network`, `channel` and `namespace` in `LabelNames` — omitting them
206+
> panics with "inconsistent label cardinality" on first use. See
207+
> [Driver Metrics](../drivers/metrics.md#pitfall-labelnames-must-include-network-channel-namespace).
208+
209+
### Who calls `Close()`
210+
211+
Application code does not normally close these caches itself: they are released by the
212+
existing teardown chain when a token management service is unloaded, for instance when
213+
its public parameters are updated.
214+
215+
```
216+
core.TMSProvider.Update (token/core/tms.go)
217+
└── Service.Done() (token/core/common/tms.go)
218+
└── wallet.Service.Done() (token/services/identity/wallet/service.go)
219+
└── role.Registry.Done()
220+
├── Close() on every wallet it created that holds resources
221+
│ └── AnonymousOwnerWallet.Close() → RecipientDataCache.Close()
222+
└── Role.Done() → LocalMembership.Close()
223+
```
224+
225+
`role.Registry.Done()` closes wallets through a local `interface{ Close() }` assertion
226+
rather than through `driver.Wallet`, so wallet types with nothing to release need not
227+
implement a no-op `Close()`. If you add a wallet type that owns a goroutine, a ticker or
228+
any other resource, give it a `Close()` method and it will be released automatically.
229+
230+
> **Note:** tests that exercise an anonymous owner wallet should `t.Cleanup(w.Close)`,
231+
> otherwise each test leaves a provisioning goroutine behind for the rest of the run.
232+
165233
## Identity Types
166234

167235
The Identity Service leverages a wrapper called **TypedIdentity** to support various identity schemes uniformly.

token/services/identity/idemix/cache/cache.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,37 @@ type IdentityCache struct {
3636
cacheTimeout time.Duration
3737
// Cache performance metrics
3838
metrics *Metrics
39-
// Cancellation function for background provisioning
39+
// provisionCtx governs the lifetime of the background provisioning goroutine.
40+
// Created at construction time and cancelled by Close.
41+
provisionCtx context.Context
42+
// cancel cancels provisionCtx. Written once, at construction time, so that Close
43+
// is safe to call concurrently with Identity.
4044
cancel context.CancelFunc
4145
}
4246

4347
// NewIdentityCache creates a new identity cache with specified size and backend.
4448
func NewIdentityCache(backed IdentityCacheBackendFunc, size int, auditInfo []byte, metrics *Metrics) *IdentityCache {
4549
logger.Debugf("new identity cache with size [%d]", size)
50+
// The provisioning goroutine must not inherit any caller's request context, but it
51+
// must still be cancellable, hence a cancellable child of context.Background.
52+
provisionCtx, cancel := context.WithCancel(context.Background())
4653
ci := &IdentityCache{
4754
backed: backed,
4855
cache: make(chan *idriver.IdentityDescriptor, size),
4956
auditInfo: auditInfo,
5057
cacheTimeout: 5 * time.Millisecond,
5158
metrics: metrics,
59+
provisionCtx: provisionCtx,
60+
cancel: cancel,
5261
}
5362

5463
return ci
5564
}
5665

57-
// Close stops the background identity provisioning.
66+
// Close stops the background identity provisioning. It is idempotent and safe to call
67+
// even if provisioning was never started, or concurrently with Identity.
5868
func (c *IdentityCache) Close() {
59-
if c.cancel != nil {
60-
c.cancel()
61-
}
69+
c.cancel()
6270
}
6371

6472
// Identity retrieves an identity from cache or generates on-demand.
@@ -70,10 +78,10 @@ func (c *IdentityCache) Identity(ctx context.Context, auditInfo []byte) (*idrive
7078

7179
c.once.Do(func() {
7280
logger.DebugfContext(ctx, "provision identities with cache size [%d]", cap(c.cache))
73-
if cap(c.cache) > 0 {
74-
var backgroundCtx context.Context
75-
backgroundCtx, c.cancel = context.WithCancel(context.Background())
76-
go c.provisionIdentities(backgroundCtx)
81+
// Do not spawn the goroutine if the cache has already been closed, otherwise a
82+
// late first call would start a goroutine that nothing will ever stop.
83+
if cap(c.cache) > 0 && c.provisionCtx.Err() == nil {
84+
go c.provisionIdentities(c.provisionCtx)
7785
}
7886
})
7987

@@ -143,6 +151,7 @@ func (c *IdentityCache) provisionIdentities(ctx context.Context) {
143151
for {
144152
identityDescriptor, err := c.backed(ctx, c.auditInfo)
145153
if err != nil {
154+
c.metrics.ProvisionFailuresCount.Add(1)
146155
logger.Errorf("failed to provision identity [%s]", err)
147156
select {
148157
case <-ctx.Done():
@@ -153,9 +162,11 @@ func (c *IdentityCache) provisionIdentities(ctx context.Context) {
153162
continue
154163
}
155164
logger.DebugfContext(ctx, "generated new idemix identity [%d]", count)
156-
c.metrics.CacheLevelGauge.Add(1)
165+
// The gauge is incremented only once the entry is actually in the channel, so a
166+
// cancellation mid-send cannot leave the reported cache level skewed.
157167
select {
158168
case c.cache <- identityDescriptor:
169+
c.metrics.CacheLevelGauge.Add(1)
159170
count++
160171
case <-ctx.Done():
161172
return

token/services/identity/idemix/cache/cache_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ package cache
99
import (
1010
"context"
1111
"errors"
12+
"runtime"
13+
"strings"
1214
"sync"
1315
"sync/atomic"
1416
"testing"
1517
"time"
1618

1719
"github.com/LFDT-Panurus/panurus/token/driver"
1820
idriver "github.com/LFDT-Panurus/panurus/token/services/identity/driver"
21+
"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/metrics"
1922
"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/metrics/disabled"
2023
"github.com/stretchr/testify/assert"
2124
"github.com/stretchr/testify/require"
@@ -29,6 +32,7 @@ func TestIdentityCache(t *testing.T) {
2932
AuditInfo: []byte("audit"),
3033
}, nil
3134
}, 100, nil, NewMetrics(&disabled.Provider{}))
35+
t.Cleanup(c.Close)
3236
identityDescriptor, err := c.Identity(t.Context(), nil)
3337
require.NoError(t, err)
3438
assert.Equal(t, driver.Identity([]byte("hello world")), identityDescriptor.Identity)
@@ -48,6 +52,7 @@ func TestIdentityCacheForRace(t *testing.T) {
4852
AuditInfo: []byte("audit"),
4953
}, nil
5054
}, 10000, nil, NewMetrics(&disabled.Provider{}))
55+
t.Cleanup(c.Close)
5156

5257
numRoutines := 4
5358
wg := sync.WaitGroup{}
@@ -77,6 +82,7 @@ func TestFetchIdentityFromBackend(t *testing.T) {
7782
c := NewIdentityCache(func(ctx context.Context, auditInfo []byte) (*idriver.IdentityDescriptor, error) {
7883
return expectedIdentity, nil
7984
}, 10, []byte("cache audit"), NewMetrics(&disabled.Provider{}))
85+
t.Cleanup(c.Close)
8086

8187
// Call with different audit info to trigger backend fetch
8288
identityDescriptor, err := c.Identity(context.Background(), []byte("different audit"))
@@ -92,6 +98,7 @@ func TestFetchIdentityFromBackendError(t *testing.T) {
9298
c := NewIdentityCache(func(ctx context.Context, auditInfo []byte) (*idriver.IdentityDescriptor, error) {
9399
return nil, expectedErr
94100
}, 10, []byte("cache audit"), NewMetrics(&disabled.Provider{}))
101+
t.Cleanup(c.Close)
95102

96103
// Call with different audit info to trigger backend fetch
97104
_, err := c.Identity(context.Background(), []byte("different audit"))
@@ -111,6 +118,7 @@ func TestFetchIdentityFromCacheTimeout(t *testing.T) {
111118
AuditInfo: []byte("timeout audit"),
112119
}, nil
113120
}, 0, nil, NewMetrics(&disabled.Provider{})) // cache size 0 to force timeout
121+
t.Cleanup(c.Close)
114122

115123
// Set short timeout to trigger timeout path
116124
c.cacheTimeout = 1 * time.Millisecond
@@ -129,6 +137,7 @@ func TestFetchIdentityFromCacheTimeoutError(t *testing.T) {
129137
c := NewIdentityCache(func(ctx context.Context, auditInfo []byte) (*idriver.IdentityDescriptor, error) {
130138
return nil, expectedErr
131139
}, 0, nil, NewMetrics(&disabled.Provider{}))
140+
t.Cleanup(c.Close)
132141

133142
// Set short timeout to trigger timeout path
134143
c.cacheTimeout = 1 * time.Millisecond
@@ -183,6 +192,7 @@ func TestFetchIdentityFromCacheNilEntry(t *testing.T) {
183192
AuditInfo: []byte("backend audit"),
184193
}, nil
185194
}, 10, nil, NewMetrics(&disabled.Provider{}))
195+
t.Cleanup(c.Close)
186196

187197
// Pre-populate the cache with nil before calling Identity()
188198
// Since cache is buffered, this completes immediately
@@ -213,6 +223,7 @@ func TestIdentityCache_Close(t *testing.T) {
213223
}
214224

215225
c := NewIdentityCache(backend, 10, nil, NewMetrics(&disabled.Provider{}))
226+
t.Cleanup(c.Close)
216227
// Set a very short timeout so we don't wait long if the cache is empty
217228
c.cacheTimeout = 1 * time.Millisecond
218229

@@ -239,3 +250,92 @@ func TestIdentityCache_Close(t *testing.T) {
239250
// may complete a few in-flight iterations before observing the stop signal.
240251
assert.LessOrEqual(t, callCount.Load(), countAfterClose+3)
241252
}
253+
254+
// provisionGoroutineName is the symbol appearing in the stack trace of the background
255+
// provisioning goroutine.
256+
const provisionGoroutineName = "cache.(*IdentityCache).provisionIdentities"
257+
258+
// provisioningGoroutines counts the running provisioning goroutines.
259+
func provisioningGoroutines() int {
260+
buf := make([]byte, 1<<16)
261+
for {
262+
n := runtime.Stack(buf, true)
263+
if n < len(buf) {
264+
return strings.Count(string(buf[:n]), provisionGoroutineName)
265+
}
266+
buf = make([]byte, 2*len(buf))
267+
}
268+
}
269+
270+
// TestIdentityCache_CloseRacesFirstUse checks Close is safe to call concurrently with
271+
// the first Identity call, and that the cancellation is never missed.
272+
//
273+
// The cancel function used to be assigned inside once.Do, so Close read it without
274+
// synchronisation: a data race, and worse, a Close that observed the old nil value
275+
// silently skipped the cancellation and leaked the goroutine. Building the context in
276+
// the constructor makes the field write-once.
277+
func TestIdentityCache_CloseRacesFirstUse(t *testing.T) {
278+
require.Eventually(t, func() bool {
279+
return provisioningGoroutines() == 0
280+
}, 5*time.Second, 10*time.Millisecond, "a previous test left a provisioning goroutine behind")
281+
282+
for range 300 {
283+
c := NewIdentityCache(func(context.Context, []byte) (*idriver.IdentityDescriptor, error) {
284+
return &idriver.IdentityDescriptor{Identity: []byte("id")}, nil
285+
}, 4, nil, NewMetrics(&disabled.Provider{}))
286+
287+
var wg sync.WaitGroup
288+
wg.Add(2)
289+
go func() {
290+
defer wg.Done()
291+
_, _ = c.Identity(context.Background(), nil)
292+
}()
293+
go func() {
294+
defer wg.Done()
295+
c.Close()
296+
}()
297+
wg.Wait()
298+
}
299+
300+
// Every cache was closed, so no provisioning goroutine may survive.
301+
require.Eventually(t, func() bool {
302+
return provisioningGoroutines() == 0
303+
}, 5*time.Second, 10*time.Millisecond, "Close missed the cancellation and leaked a goroutine")
304+
}
305+
306+
// countingCounter is a minimal metrics.Counter that keeps a running total.
307+
type countingCounter struct {
308+
total atomic.Int64
309+
}
310+
311+
func (c *countingCounter) With(...string) metrics.Counter { return c }
312+
313+
func (c *countingCounter) Add(delta float64) { c.total.Add(int64(delta)) }
314+
315+
func (c *countingCounter) value() float64 { return float64(c.total.Load()) }
316+
317+
// TestIdentityCache_CountsProvisionFailures checks a failing key manager is reported on a
318+
// counter, not only in the log, so the condition can be alerted on.
319+
func TestIdentityCache_CountsProvisionFailures(t *testing.T) {
320+
failures := &countingCounter{}
321+
c := NewIdentityCache(func(context.Context, []byte) (*idriver.IdentityDescriptor, error) {
322+
return nil, errors.New("key manager is down")
323+
}, 10, nil, &Metrics{CacheLevelGauge: &noopGauge{}, ProvisionFailuresCount: failures})
324+
t.Cleanup(c.Close)
325+
326+
_, err := c.Identity(context.Background(), nil)
327+
require.Error(t, err)
328+
329+
require.Eventually(t, func() bool {
330+
return failures.value() >= 1
331+
}, 2*time.Second, 5*time.Millisecond, "the failed provisioning attempt was not counted")
332+
}
333+
334+
// noopGauge is a metrics.Gauge that discards everything.
335+
type noopGauge struct{}
336+
337+
func (g *noopGauge) With(...string) metrics.Gauge { return g }
338+
339+
func (g *noopGauge) Add(float64) {}
340+
341+
func (g *noopGauge) Set(float64) {}

token/services/identity/idemix/cache/metrics.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,34 @@ var (
1717
Help: "Level of the idemix cache",
1818
LabelNames: []string{"network", "channel", "namespace"},
1919
}
20+
21+
// ProvisionFailuresOpts counts failed attempts to pre-provision identities. A
22+
// rising rate means the key manager is failing: identities are generated on the
23+
// request path instead and the background loop is retrying. This is the signal to
24+
// alert on, since the corresponding log line alone is easy to miss.
25+
//
26+
// LabelNames must repeat network/channel/namespace: the provider is TMS-wrapped
27+
// and binds those three on every metric, so omitting them here panics with
28+
// "inconsistent label cardinality" on first use.
29+
ProvisionFailuresOpts = metrics.CounterOpts{
30+
Name: "cache_provision_failures_total",
31+
Help: "Failed attempts to pre-provision idemix identities",
32+
LabelNames: []string{"network", "channel", "namespace"},
33+
}
2034
)
2135

2236
// Metrics contains metrics for monitoring identity cache performance.
2337
type Metrics struct {
2438
// Current number of cached identities
2539
CacheLevelGauge metrics.Gauge
40+
// Failed background provisioning attempts
41+
ProvisionFailuresCount metrics.Counter
2642
}
2743

2844
// NewMetrics creates a new Metrics instance.
2945
func NewMetrics(p metrics.Provider) *Metrics {
3046
return &Metrics{
31-
CacheLevelGauge: p.NewGauge(LevelOpts),
47+
CacheLevelGauge: p.NewGauge(LevelOpts),
48+
ProvisionFailuresCount: p.NewCounter(ProvisionFailuresOpts),
3249
}
3350
}

0 commit comments

Comments
 (0)