Skip to content

Commit 016badd

Browse files
atharrva01adecaro
authored andcommitted
feat(selector): surgical cache updates via extended token notifier
- Extend TokenRecordReference with WalletID, Type, Quantity - Update Postgres trigger to include owner_wallet_id, token_type, quantity in NOTIFY payload - Replace dirty-flag invalidation with surgical insert/delete in onTokenChange - Add 8KB payload guard to trigger (RAISE EXCEPTION on overflow) - Add TokenNotifierDisabled config flag for operators to opt out - Add BenchmarkSelectorWit Signed-off-by: Atharva Borade <atharvaborade568@gmail.com> Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
1 parent 9aecbf8 commit 016badd

10 files changed

Lines changed: 216 additions & 38 deletions

File tree

token/sdk/dig/sdk.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ func (p *SDK) Install() error {
167167
cfg.GetFetcherCacheSize(),
168168
cfg.GetFetcherCacheRefresh(),
169169
cfg.GetFetcherCacheMaxQueries(),
170+
cfg.IsTokenNotifierDisabled(),
170171
)
171172
}),
172173

token/services/selector/config/driver.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ type Config struct {
3838
FetcherCacheSize int64 `yaml:"fetcherCacheSize,omitempty"`
3939
FetcherCacheRefresh time.Duration `yaml:"fetcherCacheRefresh,omitempty"`
4040
FetcherCacheMaxQueries int `yaml:"fetcherCacheMaxQueries,omitempty"`
41+
// TokenNotifierDisabled disables the Postgres LISTEN/NOTIFY-based cache
42+
// invalidation and falls back to the time-based freshness interval.
43+
// Set to true if the notifier becomes a bottleneck under high write load.
44+
TokenNotifierDisabled bool `yaml:"tokenNotifierDisabled,omitempty"`
4145
}
4246

4347
// New returns a SelectorConfig with the values from the token.selector key
@@ -105,3 +109,9 @@ func (c *Config) GetFetcherCacheMaxQueries() int {
105109
// Return 0 if not set, which will trigger use of fetcher default
106110
return c.FetcherCacheMaxQueries
107111
}
112+
113+
// IsTokenNotifierDisabled returns true when the DB notifier should be skipped
114+
// and the selector falls back to the time-based freshness interval.
115+
func (c *Config) IsTokenNotifierDisabled() bool {
116+
return c.TokenNotifierDisabled
117+
}

token/services/selector/sherdlock/fetcher.go

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
"github.com/hyperledger-labs/fabric-token-sdk/token/driver"
2020
dbdriver "github.com/hyperledger-labs/fabric-token-sdk/token/services/storage/db/driver"
2121
"github.com/hyperledger-labs/fabric-token-sdk/token/services/storage/tokendb"
22-
"github.com/hyperledger-labs/fabric-token-sdk/token/services/utils/cache"
22+
utilcache "github.com/hyperledger-labs/fabric-token-sdk/token/services/utils/cache"
2323
token2 "github.com/hyperledger-labs/fabric-token-sdk/token/token"
2424
)
2525

@@ -38,7 +38,7 @@ const (
3838
Cached FetcherStrategy = "cached"
3939
)
4040

41-
type fetchFunc func(db *tokendb.StoreService, m *Metrics, cacheSize int64, freshnessInterval time.Duration, maxQueries int) TokenFetcher
41+
type fetchFunc func(db *tokendb.StoreService, m *Metrics, cacheSize int64, freshnessInterval time.Duration, maxQueries int, notifierDisabled bool) TokenFetcher
4242

4343
type fetcherProvider struct {
4444
tokenStoreServiceManager tokendb.StoreServiceManager
@@ -47,12 +47,13 @@ type fetcherProvider struct {
4747
cacheSize int64
4848
freshnessInterval time.Duration
4949
maxQueries int
50+
notifierDisabled bool
5051
}
5152

5253
var fetchers = map[FetcherStrategy]fetchFunc{
53-
Mixed: func(db *tokendb.StoreService, m *Metrics, cacheSize int64, freshnessInterval time.Duration, maxQueries int) TokenFetcher {
54+
Mixed: func(db *tokendb.StoreService, m *Metrics, cacheSize int64, freshnessInterval time.Duration, maxQueries int, notifierDisabled bool) TokenFetcher {
5455
var notifier dbdriver.TokenNotifier
55-
if db != nil && db.TokenStore != nil {
56+
if !notifierDisabled && db != nil && db.TokenStore != nil {
5657
var err error
5758
notifier, err = db.Notifier()
5859
if err != nil {
@@ -65,7 +66,7 @@ var fetchers = map[FetcherStrategy]fetchFunc{
6566
}
6667

6768
// NewFetcherProvider creates a new fetcher provider with the specified strategy and configuration.
68-
func NewFetcherProvider(storeServiceManager tokendb.StoreServiceManager, metricsProvider metrics.Provider, strategy FetcherStrategy, cacheSize int64, freshnessInterval time.Duration, maxQueries int) *fetcherProvider {
69+
func NewFetcherProvider(storeServiceManager tokendb.StoreServiceManager, metricsProvider metrics.Provider, strategy FetcherStrategy, cacheSize int64, freshnessInterval time.Duration, maxQueries int, notifierDisabled bool) *fetcherProvider {
6970
fetcher, ok := fetchers[strategy]
7071
if !ok {
7172
panic("undefined fetcher strategy: " + strategy)
@@ -78,6 +79,7 @@ func NewFetcherProvider(storeServiceManager tokendb.StoreServiceManager, metrics
7879
cacheSize: cacheSize,
7980
freshnessInterval: freshnessInterval,
8081
maxQueries: maxQueries,
82+
notifierDisabled: notifierDisabled,
8183
}
8284
}
8385

@@ -88,7 +90,7 @@ func (p *fetcherProvider) GetFetcher(tmsID token.TMSID) (TokenFetcher, error) {
8890
return nil, err
8991
}
9092

91-
return p.fetch(tokenDB, p.metrics, p.cacheSize, p.freshnessInterval, p.maxQueries), nil
93+
return p.fetch(tokenDB, p.metrics, p.cacheSize, p.freshnessInterval, p.maxQueries, p.notifierDisabled), nil
9294
}
9395

9496
// mixedFetcher combines both eager and lazy strategies
@@ -149,14 +151,9 @@ func (f *lazyFetcher) UnspentTokensIteratorBy(ctx context.Context, walletID stri
149151
return collections.NewPermutatedIterator[token2.UnspentTokenInWallet](it)
150152
}
151153

152-
type permutatableIterator[T any] interface {
153-
iterators.Iterator[T]
154-
NewPermutation() iterators.Iterator[T]
155-
}
156-
157154
type tokenCache interface {
158-
Get(key string) (permutatableIterator[*token2.UnspentTokenInWallet], bool)
159-
Add(key string, value permutatableIterator[*token2.UnspentTokenInWallet])
155+
Get(key string) ([]*token2.UnspentTokenInWallet, bool)
156+
Add(key string, value []*token2.UnspentTokenInWallet)
160157
Delete(key string)
161158
Clear()
162159
}
@@ -198,9 +195,9 @@ func NewCachedFetcher(tokenDB TokenDB, notifier dbdriver.TokenNotifier, cacheSiz
198195
// If cacheSize <= 0, use default size; otherwise use custom size
199196
// Both use the same default NumCounters and BufferItems
200197
if cacheSize <= 0 {
201-
ristrettoCache, err = cache.NewDefaultRistrettoCache[permutatableIterator[*token2.UnspentTokenInWallet]]()
198+
ristrettoCache, err = utilcache.NewDefaultRistrettoCache[[]*token2.UnspentTokenInWallet]()
202199
} else {
203-
ristrettoCache, err = cache.NewRistrettoCacheWithSize[permutatableIterator[*token2.UnspentTokenInWallet]](cacheSize)
200+
ristrettoCache, err = utilcache.NewRistrettoCacheWithSize[[]*token2.UnspentTokenInWallet](cacheSize)
204201
}
205202

206203
if err != nil {
@@ -225,9 +222,54 @@ func NewCachedFetcher(tokenDB TokenDB, notifier dbdriver.TokenNotifier, cacheSiz
225222
}
226223

227224
// onTokenChange is the callback registered with the token DB notifier.
228-
// Any write to the token table marks the cache dirty so the next query forces a refresh.
229-
func (f *cachedFetcher) onTokenChange(_ dbdriver.Operation, _ dbdriver.TokenRecordReference) {
230-
f.dirty.Store(1)
225+
// When the reference carries wallet/type data it surgically patches the cache
226+
// bucket so the next query sees the change without a full DB scan.
227+
// If data is missing (empty WalletID or Type) we fall back to marking the
228+
// whole cache dirty so the next query triggers a full refresh.
229+
func (f *cachedFetcher) onTokenChange(op dbdriver.Operation, ref dbdriver.TokenRecordReference) {
230+
if ref.WalletID == "" || ref.Type == "" {
231+
f.dirty.Store(1)
232+
return
233+
}
234+
key := tokenKey(ref.WalletID, ref.Type)
235+
236+
f.mu.Lock()
237+
defer f.mu.Unlock()
238+
239+
switch op {
240+
case dbdriver.Insert:
241+
toks, _ := f.cache.Get(key)
242+
newTok := &token2.UnspentTokenInWallet{
243+
Id: token2.ID{TxId: ref.TxID, Index: ref.Index},
244+
WalletID: ref.WalletID,
245+
Type: ref.Type,
246+
Quantity: ref.Quantity,
247+
}
248+
f.cache.Add(key, append(toks, newTok))
249+
f.prevKeys[key] = struct{}{}
250+
251+
case dbdriver.Delete:
252+
toks, ok := f.cache.Get(key)
253+
if !ok {
254+
return
255+
}
256+
updated := toks[:0:0]
257+
for _, t := range toks {
258+
if t.Id.TxId != ref.TxID || t.Id.Index != ref.Index {
259+
updated = append(updated, t)
260+
}
261+
}
262+
if len(updated) == 0 {
263+
f.cache.Delete(key)
264+
delete(f.prevKeys, key)
265+
} else {
266+
f.cache.Add(key, updated)
267+
}
268+
269+
default:
270+
// For updates (e.g. spendable flag toggles) fall back to a full refresh.
271+
f.dirty.Store(1)
272+
}
231273
}
232274

233275
func (f *cachedFetcher) update(ctx context.Context) {
@@ -278,7 +320,7 @@ func (f *cachedFetcher) updateCache(ctx context.Context, tokensByKey map[string]
278320
// Step 1: Add/update new entries first
279321
newKeys := make(map[string]struct{}, len(tokensByKey))
280322
for key, toks := range tokensByKey {
281-
f.cache.Add(key, iterators.Slice(toks))
323+
f.cache.Add(key, toks)
282324
newKeys[key] = struct{}{}
283325
}
284326

@@ -310,10 +352,10 @@ func (f *cachedFetcher) UnspentTokensIteratorBy(ctx context.Context, walletID st
310352
f.mu.RLock()
311353
}
312354

313-
it, ok := f.cache.Get(tokenKey(walletID, currency))
355+
toks, ok := f.cache.Get(tokenKey(walletID, currency))
314356
f.mu.RUnlock()
315357
if ok {
316-
return it.NewPermutation(), nil
358+
return iterators.Slice(toks).NewPermutation(), nil
317359
}
318360
logger.DebugfContext(ctx, "No tokens found in cache for [%s]. Returning empty iterator.", tokenKey(walletID, currency))
319361

token/services/selector/sherdlock/fetcher_test.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ func TestNewMixedFetcher(t *testing.T) {
327327

328328
func TestRistrettoCache_Integration(t *testing.T) {
329329
// Test that Ristretto cache works correctly with the fetcher
330-
c, err := cache.NewRistrettoCacheWithSize[permutatableIterator[*token2.UnspentTokenInWallet]](10)
330+
c, err := cache.NewRistrettoCacheWithSize[[]*token2.UnspentTokenInWallet](10)
331331
require.NoError(t, err)
332332
assert.NotNil(t, c)
333333

@@ -339,8 +339,7 @@ func TestRistrettoCache_Integration(t *testing.T) {
339339
Quantity: "100",
340340
},
341341
}
342-
it := iterators.Slice(tokens)
343-
c.Add("key1", it)
342+
c.Add("key1", tokens)
344343

345344
// Wait for cache to process the addition (Ristretto is async)
346345
time.Sleep(50 * time.Millisecond)
@@ -365,7 +364,7 @@ func TestRistrettoCache_Integration(t *testing.T) {
365364
func TestRistrettoCache_SizeLimit(t *testing.T) {
366365
// Test that cache respects size limit
367366
smallSize := int64(5)
368-
c, err := cache.NewRistrettoCacheWithSize[permutatableIterator[*token2.UnspentTokenInWallet]](smallSize)
367+
c, err := cache.NewRistrettoCacheWithSize[[]*token2.UnspentTokenInWallet](smallSize)
369368
require.NoError(t, err)
370369

371370
// Add items with cost=1 each
@@ -377,8 +376,7 @@ func TestRistrettoCache_SizeLimit(t *testing.T) {
377376
Quantity: "100",
378377
},
379378
}
380-
it := iterators.Slice(tokens)
381-
c.Add(tokenKey("wallet", token2.Type(string([]rune{rune(i)}))), it)
379+
c.Add(tokenKey("wallet", token2.Type(string([]rune{rune(i)}))), tokens)
382380
}
383381

384382
// Wait for cache to process additions
@@ -393,8 +391,7 @@ func TestRistrettoCache_SizeLimit(t *testing.T) {
393391
Quantity: "100",
394392
},
395393
}
396-
it := iterators.Slice(tokens)
397-
c.Add("test_key", it)
394+
c.Add("test_key", tokens)
398395

399396
// Wait for cache to process the addition
400397
time.Sleep(50 * time.Millisecond)
@@ -798,6 +795,7 @@ func TestNewFetcherProvider(t *testing.T) {
798795
100,
799796
time.Second,
800797
10,
798+
false,
801799
)
802800

803801
assert.NotNil(t, provider)
@@ -815,6 +813,7 @@ func TestNewFetcherProvider(t *testing.T) {
815813
100,
816814
time.Second,
817815
10,
816+
false,
818817
)
819818
})
820819
})
@@ -827,6 +826,7 @@ func TestNewFetcherProvider(t *testing.T) {
827826
0,
828827
0,
829828
0,
829+
false,
830830
)
831831

832832
assert.NotNil(t, provider)
@@ -852,6 +852,7 @@ func TestFetcherProvider_GetFetcher(t *testing.T) {
852852
100,
853853
time.Second,
854854
10,
855+
false,
855856
)
856857

857858
fetcher, err := provider.GetFetcher(token.TMSID{})
@@ -875,6 +876,7 @@ func TestFetcherProvider_GetFetcher(t *testing.T) {
875876
100,
876877
time.Second,
877878
10,
879+
false,
878880
)
879881

880882
fetcher, err := provider.GetFetcher(token.TMSID{})

token/services/selector/sherdlock/fetcher_unit_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func TestFetcherProviderUnit(t *testing.T) {
2121
mockSSM := &mocks.FakeTokenDBStoreServiceManager{}
2222
metricsProvider, _ := setupMetricsMocks()
2323

24-
provider := sherdlock.NewFetcherProvider(mockSSM, metricsProvider, sherdlock.Mixed, 0, 0, 0)
24+
provider := sherdlock.NewFetcherProvider(mockSSM, metricsProvider, sherdlock.Mixed, 0, 0, 0, false)
2525

2626
t.Run("GetFetcher_Error", func(t *testing.T) {
2727
mockSSM.StoreServiceByTMSIdReturns(nil, errors.New("ssm error"))

0 commit comments

Comments
 (0)