feat(selector): invalidate token cache via DB notifications#1613
feat(selector): invalidate token cache via DB notifications#1613atharrva01 wants to merge 3 commits into
Conversation
|
Hey @adecaro, I noticed the |
ea96a12 to
c8f3782
Compare
|
Hi @atharrva01 , thanks for picking this up 🙏 So, in a running system, we can have tokens being continuously added and this will trigger a refresh of the cache that will prevent the selector any read. I see a possible substantial degradation of performance here. But what if instead we take the input passed to the listener and surgically update the cache with the new entry? We would only need to keep the lock to add the new token for a very short period. What do you think? It might be good to check the benchmarks here What do you think? 😄 |
|
Hey @adecaro, you're right, I missed that. The tricky part is that Still much cheaper than a full table scan under the write lock for high-write scenarios, so the direction makes sense to me. One thing worth flagging, the cache value is currently an immutable Also the existing benchmarks only cover read throughput on a static cache, they wouldn't catch the write-contention case you're describing. I can add a concurrent-write benchmark to make the improvement visible. Does this sound like the right direction? |
|
Hi @atharrva01 , if more is needed we can modify what is put inside |
|
Hey @adecaro, had a look at the storage layer. The reference gets populated from a Postgres LISTEN/NOTIFY trigger and right now it only emits |
|
HI @atharrva01 , yes, let's try that. There is a limit to the amount of data that Postgres can send. Let's see if we are in the boundaries 🤞 |
|
@atharrva01 , if you have cycles, please, think also about a benchmark that we can run against Postgres under different loads to see how the selector react. The current benchmarks work against a fake DB. What do you think? |
|
Hey @adecaro, good news, the infrastructure is already there. |
|
Hi @atharrva01 , I was double checking and we have an 8,000 bytes limit on the payload Postgres would send back with the current approach. We need to be carefully. I was also checking the performance of this mechanism under high load. It is not design to be efficient under high load so each use case must evaluate if it makes sense to relay on this feature. So, it might make sense also to have the possibility to disable completely the token notifier in case it starts being a bottleneck. |
|
Hey @adecaro, good catches. On the 8KB limit, individual token fields should fit fine but I'll add a safeguard so the trigger fails loudly rather than silently truncating anything unexpected. For the disable option, the fetcher already falls back to time-based polling when the notifier is unavailable, I'll just wire an explicit config flag into the selector config so operators can turn it off cleanly if it becomes a bottleneck under load. |
c8f3782 to
1587f6b
Compare
|
Hey @adecaro, addressed all three points! For the surgical update, I extended For the 8KB limit, the trigger now raises an explicit exception before calling For the disable option, added Also added Let me know if anything needs tweaking! |
99af372 to
3cbd485
Compare
|
Hi @atharrva01 , thanks for the effort and patience. I will review ASAP 🙏 |
|
hey @adecaro , a gentle ping on this pr , whenever you get time , thanks :) |
8eaa2b4 to
67dbc86
Compare
|
Hi @EvanYan1024 , Many thanks |
67dbc86 to
1ae9618
Compare
|
Thanks for the ping @adecaro. Sharing one observation, for what it's worth: One concern about the benchmark added in ticker := time.NewTicker(100 * time.Millisecond)
// ...
_ = storeOne(fmt.Sprintf("write-tx-%d", n), 0, "0x64")This is 10 writes/s. Issue #884 carries the performance label and explicitly targets selector throughput under continuous DB writes. A production CBDC-style load Would it be reasonable to parameterize the write rate and add a baseline comparison? Something like: for _, rate := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("write_rate_%dwps_notifier", rate), func(b *testing.B) {
runBench(b, rate, /*notifierEnabled*/ true)
})
b.Run(fmt.Sprintf("write_rate_%dwps_lazy", rate), func(b *testing.B) {
runBench(b, rate, /*notifierEnabled*/ false) // tokenNotifierDisabled
})
}That gives six datapoints across two dimensions:
Without something like this, I'm not sure we can fully claim the PR closes the performance part of #884 — the wiring is in place, but it hasn't been shown to move the Happy to help look at the numbers once they're in. Thanks for the work on this! |
|
Took another pass and ran into three more concerns worth flagging before merge: 1. Cache value is a shared mutable slice (race risk)In toks, ok := f.cache.Get(tokenKey(walletID, currency))
f.mu.RUnlock()
if ok {
return iterators.Slice(toks).NewPermutation(), nil
}releases the read lock before the iterator is consumed. iterators.Slice (in fabric-smart-client/.../iterators/slice.go) doesn't copy — it holds the same backing array, Could the surgical update path build a fresh slice before storing? Roughly: That keeps every cache value an immutable snapshot, and readers stay safe after RUnlock.
NewCachedFetcher calls notifier.Subscribe(f.onTokenChange), but cachedFetcher doesn't expose a Close(), and Manager.Stop() only cancels the cleaner goroutine. Since fetcherProvider constructs one fetcher per TMS (fetcher.go:84-100) — not a process-wide singleton — restarting / re-creating TMSes will accumulate LISTEN
The new payload guard in notifier.go raises when octet_length(output) > 8000. Since the trigger is AFTER ... FOR EACH ROW running in the same transaction as the original Loud-fail is the right instinct over silent truncation, but making a notification failure abort the business write feels like an availability regression. A safer (Direction still feels right overall — this should help shrink the visibility window we see on the prepare side under load.) |
|
hey @EvanYan1024, all four points addressed, also @adecaro please have a look whenever you get a chance, thanks the insert race was a dumb one, switched to make+copy so readers that already grabbed the old slice don't share backing array with a concurrent writer. the delete path was fine since toks[:0:0] forces a fresh alloc on append anyway. for the close/unsubscribe, added Close() on cachedFetcher -> UnsubscribeAll(), mixedFetcher delegates it, and Manager.Stop() calls it via io.Closer type assertion so i didn't have to touch the TokenFetcher interface or regenerate mocks. the RAISE EXCEPTION thing was genuinely bad on my part, didn't think through that it runs in the same txn as the write. changed it to RAISE WARNING + RETURN NULL so an oversized payload just drops the notification and falls back to time-based refresh. and yeah, restructured the benchmark to sweep 10/100/1000 wps with notifier on and off so the comparison is actually meaningful at production-ish load. |
81ec2d3 to
7c7767a
Compare
|
@adecaro , Here are the benchmark results from a local run (-benchtime=3x, 4 CPUs, WSL2/Postgres):
Notifier and lazy are within the same order of magnitude across all write rates, no meaningful throughput regression from the LISTEN/NOTIFY path. At 1000 wps the notifier is ~17% faster, likely because the cache stays warm from continuous notifications and avoids the time-based staleness check on each query. |
17bd0c3 to
a017d05
Compare
|
Hey @adecaro, just wanted to say this PR ended up being a really good learning experience. Going through the review cycle helped me understand things I wouldn't have picked up just reading the code. The surgical cache update was the most interesting part. I initially went with a simple dirty flag and full refresh, but patching the cache bucket directly forced me to understand how the mu lock interacts with readers after RUnlock. The shared backing array race was something I completely missed, I was appending onto the existing slice assuming cache.Add was enough isolation, but the iterator holds the same array and a concurrent writer can land on a slot the reader is still walking. The make+copy fix is obvious in hindsight. The RAISE EXCEPTION in the trigger was a genuine blind spot. I didn't think through that the trigger runs inside the same transaction as the INSERT, so an exception would abort the token store write entirely. RAISE WARNING + RETURN NULL was the right call. Two things I caught while doing a final self-review: Subscribe now returns a targeted cancel func instead of UnsubscribeAll() so other subscribers on the same notifier aren't affected, and I added tests covering the actual slice mutation paths in onTokenChange, all previous notifier tests used empty WalletID so they only hit the dirty-flag fallback. Thanks for the patience through the back-and-forth, learned a lot from this one. |
ee1e877 to
ad29b28
Compare
|
Thanks for the work here — wiring the selector cache to token DB notifications is definitely the right direction, and I like that the PR also keeps a time-based fallback. I had one concern after reading through the cache update path: the surgical INSERT/DELETE optimization seems to rely on the notification payload being equivalent to the canonical selector query, but today it only carries tx_id/idx/wallet/type/quantity. The normal SpendableTokensIteratorBy path also filters on things like owner, is_deleted, spendable, and supported ledger formats, so I’m a bit worried the incremental path could admit or retain entries that a full DB refresh would not. There are also a couple of cache-consistency edge cases that might be worth tightening before merge:
Given that, I wonder if we could make this PR a more conservative first step: use the notifier only to mark the cache dirty on INSERT/DELETE/UPDATE, so the next selector query refreshes immediately. That should still address the original freshness issue without waiting for freshnessInterval, while keeping the surgical cache update as a follow-up once the payload/filtering/idempotency semantics are fully covered. Happy to be corrected if I missed part of the storage contract here. cc @adecaro |
|
Hi @EvanYan1024 , thanks for these additional thoughts. Let me address them:
Very good point. Let's clarify. To the best of my understanding:
The other strategy is to perform in all cases an additional query to retrieve to the token information. This way, the trigger can just return the tx_id and index.
Good point, better to check here.
Very interesting. We need to make sure the cache gets refreshed or if no cache is there, we can drop the event altogether.
Fair point, indeed.
Don't we risk serious contention here if many tokens are inserted/updated/deleted? The cache will be invalidated continuously, no? Actually, So, if the above issue can be avoided, why not splitting the PR in two. Sure. @atharrva01 , what do you think?
Super interesting, thanks. I'll remove the approve until we decide the next steps. Thanks both 🙏 |
|
hi @adecaro Agreed, splitting makes sense. The surgical update path has real edge cases (eviction, idempotency, filter mismatch) that deserve proper coverage before merging. I'll clean this PR down to just the dirty-flag subscription and open a follow-up for the surgical updates once those are addressed. shall i proceed? |
|
Hi @atharrva01 , please, go ahead. Double check what happens during the benchmark when the notifier triggers events. We need to make sure the system doesn't get overwhelmed. Thanks much. This is taking more time but I think we are learning a lot 🙏 |
|
Thanks @adecaro, proceeding with the split now. On the benchmark concern: with dirty-flag-only, the dirty flag coalesces all notifications that arrive between two fetch calls no matter how many INSERT/DELETE/UPDATE events fire, we only do one full DB refresh per selector query that finds dirty=1. So DB call rate is bounded by query rate, not write rate. The existing benchmark results (notifier ±same order of magnitude as lazy, ~17% faster at 1000 wps) already cover this path since they measure fetch throughput. I'll strip the surgical code and re-run to confirm the numbers hold. |
ad29b28 to
7121654
Compare
|
@adecaro done, surgical update code stripped, PR is now dirty-flag only. Benchmark results (3x, 4 CPUs, WSL2/Postgres):
At 1000 wps (worst case) notifier is ~8% slower than lazy. The reason: at sustained high write rates the dirty flag is set on every notification, so every fetch hits the DB, essentially equivalent to the lazy path with a small notification overhead. The key property is that the dirty flag coalesces all notifications that arrive between two fetch calls into a single DB refresh, so DB call rate is bounded by query rate, not write rate. No overwhelming. What is in this PR now:
Follow-up PR will cover surgical cache updates (INSERT/DELETE patching without full DB scan) once the eviction, idempotency, and filter-coverage edge cases raised by @EvanYan1024 are properly addressed. |
7121654 to
8406074
Compare
|
Hi @atharrva01 , thanks much, I'll review ASAP 🙏 |
| // onTokenChange is the callback registered with the token DB notifier. | ||
| // Any token DB change marks the cache dirty, forcing a full refresh on the next query. | ||
| func (f *cachedFetcher) onTokenChange(_ dbdriver.Operation, _ dbdriver.TokenRecordReference) { | ||
| f.dirty.Store(1) |
There was a problem hiding this comment.
what about setting the dirty flag only if a refresh is not already in progress? Could this improve the performance?
|
Hi @atharrva01 , thanks for your effort and patience. I left a comment. I want to understand how to improve the performance by setting the |
|
hi @adecaro, good thought but there's a race hiding there, update() clears dirty before the DB call starts, so any token written during that window won't be in the snapshot. if we skip setting dirty when isUpdating is true, that token gets silently dropped and the cache stays stale until the next time-based refresh. the always-set behavior is intentional for exactly this reason. also checking isUpdating from the callback would need f.mu, which adds contention on every notification. |
|
Hello @adecaro, I need your help with this PR. Could you please have look at this PR? What next? Regards, |
Wire the cachedFetcher to the token DB notifier so any INSERT, UPDATE, or DELETE on the tokens table immediately marks the cache dirty. The next selector query then bypasses the freshnessInterval and fetches from the DB rather than waiting for the time-based TTL to expire. The notifier subscription is stored as a cancel func (not UnsubscribeAll) so the cachedFetcher can silence only its own callback on Close() without affecting any other subscriber on the same notifier. Close() is called by mixedFetcher.Close(), which is in turn called by Manager.Stop() via an io.Closer type assertion -- no TokenFetcher interface change needed. A notifierDisabled config flag lets operators opt out of the LISTEN/NOTIFY path and fall back to pure time-based refresh. Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Issue 2 — replace UnsubscribeAll with a per-subscription cancel func: TokenNotifier.Subscribe now returns (func() error, error). The cancel func silences only the callback registered by this fetcher via an atomic flag, so other subscribers on the same notifier are unaffected. cachedFetcher stores the cancel func and calls it from Close() instead of calling UnsubscribeAll() which was a blunt instrument. Issue 1 — add unit tests for the surgical cache update paths: Every previous notifier.fire() call used empty WalletID/Type, exercising only the dirty-flag fallback. New tests cover: - SurgicalInsert_NewKey: Insert with full metadata adds token to cache without a DB round-trip - SurgicalInsert_ExistingKey: second Insert appends to existing slice - SurgicalDelete_RemovesToken: Delete removes only the matching token - SurgicalDelete_LastToken: deleting the last token removes the cache key - SurgicalUpdate_SetsDirty: Update falls back to dirty for full refresh Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The trigger reads owner_wallet_id from the tokens table row, not from the ownership table populated by the owners slice. Without OwnerWalletID set on the record, the tokens row had an empty owner_wallet_id and the assertion for WalletID:"alice" in the notification payload failed. Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
8406074 to
f3721c4
Compare


Summary
Wire the
cachedFetcherin the sherdlock selector to Postgres token DB notifications so any INSERT, UPDATE, or DELETE on the tokens table immediately marks the cache dirty. The next selector query then bypassesfreshnessIntervaland fetches from the DB rather than waiting for the time-based TTL to expire.Surgical cache updates (patching individual cache buckets on INSERT/DELETE without a full DB scan) are intentionally deferred to a follow-up PR. That path has correctness edge cases (Ristretto eviction, notification idempotency, filter-field coverage) that deserve dedicated coverage before merging.
Changes
token/services/storage/db/driver/token.go:TokenNotifier.Subscribenow returns(func() error, error). The cancel func silences only this subscription via anatomic.Bool; other subscribers on the same notifier are unaffected.TokenRecordReferenceextended withWalletID,Type,Quantity(already emitted by the Postgres trigger).token/services/storage/db/sql/postgres/tokens.go:Subscribewraps the callback with anatomic.Boolgate and maps the trigger payload fields toTokenRecordReference.token/services/selector/sherdlock/fetcher.go:cachedFetchersubscribes to the notifier on construction;onTokenChangesetsdirty=1for all operations;Close()calls the cancel func.mixedFetcher.Close()delegates;Manager.Stop()calls it viaio.Closerassertion.token/services/selector/sherdlock/manager.go:notifierDisabledconfig flag lets operators opt out of LISTEN/NOTIFY and fall back to pure time-based refresh.Subscribesignature.Benchmark (3x, 4 CPUs, WSL2/Postgres)
At 1000 wps (worst case) the notifier is ~8% slower than lazy because every fetch hits the DB -- the dirty flag is set continuously. The performance is within the same order of magnitude at all write rates. The dirty flag coalesces multiple notifications between queries so DB call rate is bounded by query rate, not write rate.