Skip to content

Fix ZADD-based cache key resolution and parallelize subscription startup#4790

Open
denis-chernov-smartcontract wants to merge 9 commits intomainfrom
DS/zadd-fix-to-resolve-cache-keys
Open

Fix ZADD-based cache key resolution and parallelize subscription startup#4790
denis-chernov-smartcontract wants to merge 9 commits intomainfrom
DS/zadd-fix-to-resolve-cache-keys

Conversation

@denis-chernov-smartcontract
Copy link
Copy Markdown
Contributor

Closes #DS-2819

Description

Summary

Replaces fragile Go-side replication of JS adapter parameter transforms with dynamic cache key learning via ZADD interception
Parallelizes subscription startup to reduce cold-start time from ~25 minutes to seconds for ~300 feeds
Fixes overrides handling so payloads with overrides are forwarded intact to the JS adapter

Background

The Go streams-adapter acts as a caching proxy in front of a JS adapter. When a client requests data, the Go layer looks up the result in cache using a deterministic key derived from request parameters. When the JS adapter stores data, it writes to the Redcon server using keys that reflect its own internal parameter transformations (param aliases like from→base, per-adapter overrides like IDR→indodaxidr, custom transforms like CFBenchmarks index construction).

The problem: The Go layer was trying to replicate all of the JS adapter's transformation logic to compute matching cache keys. This was incomplete, fragile, and produced mismatches whenever the JS transforms diverged from the Go reimplementation.

The second problem: Subscriptions were processed serially — one asset at a time with a ~5 second round-trip each. With ~300 feeds at startup, this took ~25 minutes before data was available.

How the new approach works
KeyMapper: dynamic cache key learning
The new KeyMapper component (helpers/keymapper.go) learns the mapping between Go-computed "raw" cache keys and JS-computed "transformed" cache keys at runtime:

Raw cache key — computed by the Go layer with only endpoint alias resolution (e.g., "crypto"→"price"). No param aliases, no overrides, no custom transforms.

Transformed cache key — derived from the ZADD member the JS adapter writes to its *-subscriptionSet sorted set during subscription processing.

Learning flow — On cache miss, SubscribeAndLearn sends a subscription request to the JS adapter. The JS adapter processes it and writes a ZADD. The Redcon handleZAdd handler intercepts this and calls KeyMapper.NotifyZAdd(), delivering the transformed params via a channel. The KeyMapper computes the transformed cache key and stores the raw→transformed mapping.

Lookup flow — On subsequent requests, the handler computes the raw key, looks up the transformed key via KeyMapper.Get(), and retrieves the cached observation. A direct raw-key fallback handles the common case where no transformation occurs.

Two-phase subscription (concurrency)
On cache miss, the adapter runs two phases in a background goroutine:

Phase 1 — Immediately sends a subscription request with the original client data (including overrides). Fully concurrent — all feeds fire at once. The JS adapter queues them internally.

Phase 2 — Calls SubscribeAndLearn, which is serialized per endpoint via a per-endpoint mutex. It re-sends the subscription (a no-op since the feed is already active from Phase 1) and waits for the ZADD notification to learn the key mapping. Serialization ensures reliable attribution of ZADD notifications.

Overrides handling
subscribeToAsset now accepts interface{} instead of RequestParams, forwarding the original client request data (including overrides) intact to the JS adapter. The Go layer strips overrides only for its own cache key computation.

Changes

New files

File Description
helpers/keymapper.go KeyMapper struct: learns and stores raw→transformed cache key mappings via ZADD interception
helpers/keymapper_test.go Tests: learn flow, timeout, already-mapped skip, per-endpoint serialization

Deleted files

File Reason
helpers/transforms.go Per-adapter transform functions (e.g., cfbenchmarksTransform). No longer needed — JS transforms are learned dynamically via ZADD.

Modified files

File Changes
server/server.go Rewrote adapterHandler: KeyMapper-based cache lookups, two-phase subscription goroutine, subscribeToAsset accepts interface{} for overrides passthrough. Added GET /cache debug endpoint.
helpers/aliases.go Simplified from full param+override alias resolution to endpoint-only alias resolution. Removed paramAlias, requiredParams, override handling, adapterTransforms integration. Structs reduced from 6 to 2.
cache/cache.go Get() now accepts a string key and returns *CacheItem (was RequestParams*Observation). Removed unused Keys(), Size() methods and Cache interface assertion.
common/types.go Removed unused Cache, Config, Server interfaces and RawParams field from CacheItem.
redcon/redcon.go Added KeyMapper field. handleZAdd now calls KeyMapper.NotifyZAdd() for subscription set writes. Removed unused Stop() method and server field. Unexported SortedSetMember.
main.go Creates KeyMapper and wires it to both Server and RedconServer.
metrics/metrics.go Unexported label constants (only used within package).
helpers/helpers.go Unexported normalizeString (only used within package).
generate-endpoint-aliases/index.ts Simplified output to endpoint aliases only (removed param specs from generated JSON).

Test changes

  • cache/cache_test.go — Updated for new Get(string) signature; replaced Size()/Keys() calls with Items().
  • aliases_test.go — Removed param alias, override, and transform tests (logic removed). Simplified sampleConfig.
  • helpers_test.go — Removed TestRequestParamsFromKey_ParamAliasesResolved.
  • server/server_test.go — Updated New() call signature (now takes KeyMapper).
  • redcon/redcon_test.go — Removed unused lastWrite test helper.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 1, 2026

⚠️ No Changeset found

Latest commit: f2621cb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown
Contributor

@ro-tex ro-tex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

I have some non-blocking comments but those can be satisfied in subsequent PRs.

Comment on lines +73 to +75
epLock := km.getEndpointLock(endpointTransport)
epLock.Lock()
defer epLock.Unlock()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: A minor performance optimisation would be to move this locking operation after the check whether we already got the key.

// endpoint alias can be found.
func findEndpointInKey(key string) (string, error) {
if activeAliasIndex == nil {
if activeIndex == nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a unit test for this func and it would be great to have one.

km.mu.Unlock()
km.logger.Debug("Learned key mapping", "rawKey", rawCacheKey, "transformedKey", transformedKey)
}
case <-time.After(2 * time.Second):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All hard-coded numbers should be named constants. This will allow us to easily clean them up later and turn them into configuration options.

Comment on lines +78 to +81
if _, ok := km.Get(rawCacheKey); ok {
subscribeFn()
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We return if we already have a mapping for this raw key. We don't return if we don't have a mapping but we're already waiting for one, thus allowing us to amass a number of parallel waiting goroutines.

I am not sure how plausible this situation is, but if it is plausible, I would suggest refreshing the timeout period on the wait each time we get a hit on a raw key that we're waiting on and then returning.

Feel free to ignore if your assessment is that this cannot cause a considerable draw on resources - there's no need to do extra work if the max number of parallel waiting threads will be under, say, 50 or 100.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it's already being handled by the alreadySubscribing := s.subscriptionTracker.LoadOrStore() part. Please mark as resolved, if so.

Comment on lines +328 to +334
// Phase 2: Learn the raw→transformed cache key mapping.
// Serialized per endpoint via KeyMapper's existing mutex.
// The re-subscribe is fast because the JS adapter already has
// the subscription active from phase 1.
s.keyMapper.SubscribeAndLearn(params["endpoint"], key, func() {
s.subscribeToAsset(originalData)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we starting to listen for the subscription event after we've issued it? I might be misreading this.


// Remove from tracker after subscription attempt completes.
// Allow retries after 10 seconds if data still not available.
time.Sleep(10 * time.Second)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about magic numbers which should be named constants.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants