/config/database/shared-state.llms.txt
Shared state
Scale eRPC horizontally and every pod independently tracks upstream block heights. Without coordination, one pod may route a request to a lagging upstream while another pod wouldn't. Shared state fixes this: a background pipeline publishes every new block observation to a common store and fans updates out to every peer, so all pods route from the same global picture. The request path never waits — reads stay in-process and the network round-trip is always off the critical path.
Quick taste
Illustrative, not a tuned production config — connect all pods to one Redis instance:
database: sharedState: # unique key per deployment — prevents cross-pod contamination if two clusters share one Redis clusterKey: "prod-main" connector: # redis gives sub-millisecond cross-pod propagation via pub/sub driver: redis redis: uri: "redis://:secret@redis.internal:6379/?pool_size=10"Agent reference
Copy one of these prompts into your AI agent session (Claude Code, Cursor, …) — each one points the agent at this page's machine-readable reference so it can do the work correctly:
Prompt Example #1: add shared state to a multi-pod deployment
I'm running eRPC on multiple pods and they each independently track upstream block heights, causing inconsistent routing. Set up database.sharedState in my eRPC config using Redis so all pods share the same block-number view. Set a unique clusterKey for my deployment and leave the foreground latency defaults unless there's a strong reason to change them. Read the full reference first: https://docs.erpc.cloud/config/database/shared-state.llms.txt
Prompt Example #2: tune foreground latency for co-located Redis
My eRPC pods use shared state with Redis on the same host (sub-millisecond RTT). Audit my eRPC config sharedState settings — specifically updateMaxWait, lockMaxWait, fallbackTimeout, and lockTtl — and tighten the foreground latency budget without breaking the lockTtl > fallbackTimeout constraint. Reference: https://docs.erpc.cloud/config/database/shared-state.llms.txt
Prompt Example #3: debug inconsistent block routing across pods
Some of my eRPC pods are routing requests to lagging upstreams even though other pods see a higher block. I have sharedState configured in my eRPC config. Walk me through what Prometheus metrics and log messages to check to confirm cross-pod propagation is working, and whether my clusterKey is set correctly so pods aren't cross-contaminating each other. Reference: https://docs.erpc.cloud/config/database/shared-state.llms.txt
Prompt Example #4: migrate from DynamoDB to Redis for shared state
Our eRPC shared state currently uses DynamoDB and we're seeing ~5s cross-pod convergence lag. Migrate the database.sharedState connector in my eRPC config to Redis for sub-millisecond pubsub propagation. Keep the clusterKey unchanged so existing counter state is preserved at the new connector. Reference: https://docs.erpc.cloud/config/database/shared-state.llms.txt
Prompt Example #5: run two independent clusters on one Redis
I have a staging and a production eRPC deployment both pointing at the same Redis instance. Ensure their shared-state counters don't interfere with each other by setting distinct clusterKey values in each my eRPC config, and explain what happens if I accidentally leave both at the default. Reference: https://docs.erpc.cloud/config/database/shared-state.llms.txt
Shared state — full agent referenceExpand for every option, default, and edge case — or copy this entire section into your AI assistant.
How it works
Registry and key space. NewSharedStateRegistry wraps one Connector and a clusterKey prefix. Every counter is stored under <clusterKey>/<key> — for example prod-main/v2/latestBlock/<upstreamId>/<sha256-hex> (see Counter families below). The registry holds a sync.Map of live counterInt64 instances; calling GetCounterInt64 with the same key always returns the same object (idempotent LoadOrStore). On first call, two async bootstrap tasks start: one seeds the local counter with a single remote Get, and one calls WatchCounterInt64 to open a long-lived change stream from the connector.
Write path. TryUpdate is synchronous and local-only — it atomically advances the in-memory counter via a CAS loop and then schedules a background push without touching the network. scheduleBackgroundPushCurrent coalesces concurrent pushes: a single goroutine per counter (1) publishes the current state via connector.PublishCounterInt64 for fast peer notification, (2) acquires the distributed lock within lockMaxWait, (3) reads remote state and adopts it if the remote value is newer, then writes local state if local is newer. If the lock cannot be acquired within lockMaxWait, the goroutine falls back to publish-only propagation and reschedules if another update arrived while it was running.
Foreground latency budget. TryUpdateIfStale is used by the EVM state poller to debounce expensive RPC calls. It launches the upstream RPC in a background goroutine and waits at most updateMaxWait for the result. If the RPC finishes within that window the fresh value is returned synchronously; otherwise the stale local value is returned immediately and the RPC completes asynchronously. The foreground path never acquires the distributed lock. One caller may wait longer: a request naming the finalized tag at its idle edge, when fewer than the quorum's voters reported finalized within the freshness window W, waits for its finalized refresh up to W, past updateMaxWait (see block tracking).
Rollback handling. All counter updates enforce a rollback policy. latestBlock and finalizedBlock use ignoreRollbackOf = 1024 — decreases up to 1024 blocks are silently discarded as noise from lagging upstreams; a larger gap applies only once the lower side has moved: a later reading above the first lower one, with no reading within 1024 of the counter in between (admitLargeRollback). A genuine deep reorg or a chain restarted lower keeps moving from its new height, so it applies at its second reading and triggers the OnLargeRollback callback; a provider answering now and then from a node stuck far behind repeats one value, and its readings at the real head in between clear the count, so it never applies. Lower readings alone for 5 minutes (largeRollbackMaxHold) also apply, so a chain that restarted lower and halted is still followed. earliestBlock uses ignoreRollbackOf = 0 — all decreases are accepted because node pruning genuinely shifts the historical anchor.
Timestamp order and conflict resolution. Each counter state is serialised as {"v":<int64>,"t":<unix_ms>,"b":"<instance_id>"}. The t field is the reconcile order key, by the writer's clock; it never decides freshness. allocateUpdatedAtMs ensures local timestamps are strictly monotonic even under concurrent goroutines (a CAS spin-loop). Head counters (latestBlock, finalizedBlock) are max-only on every path (pubsub, initial fetch, reconcile, background push): a higher remote value is taken whatever its timestamp, so a writer whose clock runs ahead cannot freeze the others; timestamps decide only drops larger than ignoreRollbackOf. Other counters (earliestBlock, cordon, SVM floors) adopt a remote state only when st.UpdatedAt > currentTs. When a remote update has a newer timestamp but a value that would imply a small rollback (within ignoreRollbackOf), advanceTimestampPast is called to advance the local order key past the remote's so the local (higher) value wins the next reconciliation cycle.
Transport differences by connector. All five connectors implement the same GetCounterInt64 / PublishCounterInt64 / WatchCounterInt64 interface; propagation latency differs significantly:
- Redis — push via
PUBLISH counter:<fullKey>(e.g.counter:prod-main/v2/latestBlock/<upstreamId>/<sha256-hex>) and subscribe via a single sharedPSubscribe("counter:*")connection that matches all counters regardless of cluster; subscribers filter by exact key insiderunMessageLoop. Reconnects automatically with exponential backoff (1 s → 30 s with jitter) and a 5-minute periodic poll fallback;pollAllKeys()is triggered after each reconnect to close the gap during the reconnection window. Subscriber channels are buffered (size 1, drop-on-full) so slow consumers never block the message loop. Source:data/redis_pubsub_manager.go:L155-460,data/redis.go:L591 - PostgreSQL — push via
pg_notifyusing a channel name derived bysanitizeChannelName(fmt.Sprintf("counter_%s", key))to strip characters illegal in PostgreSQL NOTIFY channel names; LISTEN-based subscription with a 30 s polling fallback. Source:data/postgresql.go:L620-718 - DynamoDB — no push primitive.
PublishCounterInt64is a documented no-op; propagation relies on polling atstatePollInterval(default 5 s). Each poll delivers the stored state whenever it differs from the last one delivered, an older timestamp included: the counter decides. Cross-pod convergence latency is bounded by the poll interval, not a push event. Source:data/dynamodb.go:L715-766 - Memory — both
WatchCounterInt64andPublishCounterInt64are no-ops; in-process only, no cross-pod coordination. Source:data/memory.go:L195-207 - gRPC — both
WatchCounterInt64andPublishCounterInt64return an error; the gRPC connector cannot participate in shared state.
Deadlock-prevention invariant. The foreground paths (TryUpdate and TryUpdateIfStale) never acquire the distributed lock. Only the background scheduleBackgroundPushCurrent goroutine acquires it, and only after any local update is complete. This strict lock-ordering rule was introduced to fix a deadlock where the old design could have scheduleBackgroundPushCurrent hold the distributed lock and wait for updateMu, while TryUpdateIfStale held updateMu and waited for the distributed lock. The test TestLockOrderSimulation_OldBuggyBehavior documents the old behavior.
Counter families. These counters are materialised during normal operation, all stored under <clusterKey>/<key> in the backing store:
v2/latestBlock/<upstreamKey>— latest block per upstream (ignoreRollbackOf=1024).v2/finalizedBlock/<upstreamKey>— finalized block per upstream (ignoreRollbackOf=1024).v2/earliestBlock/<upstreamKey>/<probeType>— earliest available block per upstream per probe type (ignoreRollbackOf=0; all decreases accepted because node pruning genuinely shifts the historical anchor).v2/operatorCordon/<projectId>/<upstreamKey>— operator cordon state per upstream.network/<projectId>/<networkId>/finalizedBlock— the highest network-widefinalizedany instance served (see Finalized floor) (ignoreRollbackOf=1024).
<upstreamKey> is common.UniqueUpstreamKey(up): the upstream id followed by a hash of its id, endpoint and JSON-RPC headers. It does not depend on chain detection, so it stays the same for the upstream's lifetime. Two upstreams share a key only when they call the same endpoint with the same headers, and so report the same chain. Changing an upstream's endpoint or headers starts a new counter, and so does upgrading from a version that also hashed the network id; the old keys are left behind in the store. Source: common/upstream.go:L68-82
Instance ID. Each counter state carries an UpdatedBy field ("b" in JSON) identifying which pod wrote it. It is resolved from the first non-empty value of: env INSTANCE_ID → env POD_NAME → env HOSTNAME → os.Hostname() → "unknown". This is diagnostic-only and does not affect routing. Source: data/shared_state_registry.go:L82-98
SuggestLatestBlock fast path. When the eRPC proxy observes a block number in an upstream response, it immediately calls SuggestLatestBlock(blockNumber) which calls TryUpdate if the new value is greater than the current local value. This avoids a full polling round-trip and immediately triggers background propagation, advancing the shared counter without waiting for the next polling tick. Source: architecture/evm/evm_state_poller.go:L459-481
IsStale semantics. IsStale(d) reads this instance's clock only: it returns true unless the counter's report time (ReportAt: a local report's evidence time, a remote value's receive time) or the end of its last refresh (an answer, an error or the foreground timeout) is within d. It is the gate inside TryUpdateIfStale before attempting a refresh. Source: data/shared_state_variable.go:L133-141
OnValue / OnLargeRollback callbacks. Registered at counter construction time; called synchronously from triggerValueCallback within both processNewValue (local write path) and processNewState (remote sync path) after a successful CAS. The EVM state poller registers OnValue to push the new block number into the health tracker for metrics and OnLargeRollback to record large rollback events. Source: architecture/evm/evm_state_poller.go:L126-139
Rollback write (rewind). When the network-wide chain tracker re-anchors its pins, or moves to a lower head, each upstream whose own hash vote is on the new branch and whose latest counter is above that vote has the counter lowered to the vote's height through EvmStatePoller.RewindLatestBlock → Rewind. A rewind takes any drop, fires OnRewind (the health tracker's per-upstream head follows it) and then OnValue, never OnLargeRollback, takes a fresh UpdatedAt and is published with "r":true. Another instance takes a rewind-marked lower state newer than its own, without pushing back; an unmarked lower state keeps the rules above. While a counter's value comes from a drop (a large rollback or a rewind), a higher state stamped no later than the drop predates it and is rejected (stale_timestamp), on pubsub and in reconcile, until the counter rises again. Source: data/shared_state_variable.go:L637-692
Poll timeout in EVM state poller. The state poller computes its per-tick context timeout as lockTtl + 15s (minimum 30 s) to allow time for acquiring the distributed lock plus network operations. Source: architecture/evm/evm_state_poller.go:L174-187
Bootstrap task retry. If WatchCounterInt64 fails at startup (connector not ready, network error), initCounterSync marks the watch task as failed and the Initializer retries it in the background. During this window the counter operates in local-only mode. Source: data/shared_state_registry.go:L168-222
Served tip. Each instance computes the served tip from the shared per-upstream counters (see Block tracking). The one network-level counter is network/<project>/<network>/finalizedBlock, the highest network-wide finalized any instance served: a floor for finalized only while a voter of the reading instance reports it. No served latest is shared. Per-group served-tip lanes are in-process, not shared state.
Fallback when absent. erpc/erpc.go:L49-L66 synthesises an in-memory registry if sharedState == nil; the same GetCounterInt64 API works identically but no cross-pod propagation occurs. Init failures are non-fatal: erpc/init.go logs a warning and continues with a synthesised in-memory registry. A broken Redis config therefore produces degraded per-pod behavior, not a hard startup failure.
Config schema
All fields are under database.sharedState in erpc.yaml. The block is optional; its absence causes a silent fallback to a process-local in-memory registry. Struct at common/config.go:L269-286, defaults at common/defaults.go:L789-828, validation at common/validation.go:L263-309.
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
database.sharedState | *SharedStateConfig | nil | Root pointer. nil by default; when absent, erpc/erpc.go:L49-L66 synthesises an identical in-memory config at runtime. The same GetCounterInt64 API works in both cases; the only difference is no cross-pod propagation. Source: common/config.go:L266, erpc/erpc.go:L49-66 |
database.sharedState.clusterKey | string | Inherits top-level clusterKey (default "erpc-default") | Prefix prepended to every counter key as <clusterKey>/<key>. Multiple logical clusters can share the same Redis/PG instance without collision. Footgun: all instances default to "erpc-default" — distinct independent deployments pointing at the same store must set distinct keys or they cross-contaminate block-number state. Source: common/config.go:L271, common/defaults.go:L803-804 |
database.sharedState.connector | *ConnectorConfig | Auto-defaulted to in-memory (driver="memory") when block is present but connector is omitted | Backing store for counter persistence and pub/sub. Drivers: memory, redis, postgresql, dynamodb. GOTCHA — id auto-assignment: if connector.id is empty, shared-state scope sets it to string(connector.driver) (e.g. "redis") at common/defaults.go:L799-L801, NOT "shared-state-redis" (the scope+"-"+driver pattern used by cache/auth connectors). Copying a connector block from database.evmJsonRpcCache into database.sharedState and relying on auto-assigned ids produces different ids. Always set connector.id explicitly. Source: common/defaults.go:L799-801 |
database.sharedState.fallbackTimeout | Duration | 3s | Deadline for background remote I/O (Get, Set, Publish) and the hard cap on the async-refresh helper goroutine (fallbackTimeout + 1s). Does NOT bound request-path latency — that is updateMaxWait. Setting this very low degrades background propagation without improving client latency. Source: common/defaults.go:L809-812, data/shared_state_variable.go:L426, data/shared_state_variable.go:L511 |
database.sharedState.lockTtl | Duration | 4s | Expiry of the distributed lock key in the backing store. Must exceed fallbackTimeout (enforced by validation) to allow one full Get+Set cycle under the lock. If the lock expires while the goroutine is still working, Unlock logs "lock was already expired" at DEBUG level — this is expected. Source: common/defaults.go:L814-817, common/validation.go:L292-295 |
database.sharedState.lockMaxWait | Duration | 100ms | Maximum time the background push goroutine waits to acquire the distributed lock before giving up and relying on publish-only propagation. Never blocks the request path. Must be > 0 and < fallbackTimeout. Source: common/defaults.go:L820-823, common/validation.go:L298-301 |
database.sharedState.updateMaxWait | Duration | 50ms | Primary foreground latency knob. TryUpdateIfStale waits at most this long for the upstream RPC before returning the stale value and continuing async. Fast Redis responses (<5 ms typical) complete synchronously; slow responses are cut off. Must be > 0 and < fallbackTimeout. Source: common/defaults.go:L824-827, data/shared_state_variable.go:L447 |
Top-level clusterKey (common/config.go:L40): defaults to "erpc-default". Propagated to database.sharedState.clusterKey if that field is empty.
DynamoDB-specific statePollInterval (common/config.go:L441): default 5s. Controls the polling rate inside WatchCounterInt64 since DynamoDB has no pub/sub primitive.
Worked examples
All patterns below are distilled from real production fleets; comments explain the non-obvious choices.
1. Minimal Redis config with an explicit cluster key (recommended baseline). Every production
deployment sets a unique clusterKey so that staging and production instances sharing one Redis
instance don't contaminate each other's block-number state. The explicit connector.id avoids the
auto-assignment gotcha where shared-state connectors get "redis" instead of "shared-state-redis":
database: sharedState: # unique per deployment — prevents cross-pod block-height contamination # if two clusters share the same Redis instance (e.g. prod + staging) clusterKey: "prod-us-east-1" connector: # always set id explicitly: auto-assigned id here is "redis", not "shared-state-redis" id: "shared-state-redis" driver: redis redis: uri: "${SHARED_STATE_REDIS_URL}"2. Multi-region fleet with relaxed lock and wide foreground wait. When pods span
multiple regions, the Redis round-trip from any one pod may vary. This production multi-region config
raises lockTtl to 30 s to accommodate the longer distributed-lock cycle, sets lockMaxWait
to 200 ms to fail fast and fall back to publish-only propagation rather than blocking,
and raises updateMaxWait to 2 s to give the foreground refresh more time before falling
through to a stale value:
database: sharedState: clusterKey: "fly-prod-aggregator-shard0" # long TTL covers one full Get+Set cycle at cross-region Redis latency; # must stay > fallbackTimeout (default 3s), so 30s is generous lockTtl: "30s" # fail fast on lock contention — proceed with publish-only; never block requests lockMaxWait: "200ms" # cross-region Redis RTT can be 50–200ms; give the refresh goroutine time to finish # synchronously before falling through to the stale value updateMaxWait: "2s" connector: driver: redis redis: uri: "redis://erpc-redis.internal:6379"3. Same Redis, two logical clusters (staging + prod). When you can't provision separate Redis
instances, use distinct clusterKey values. Counter keys are prefixed with the cluster key so
staging/latestBlock/... and prod/latestBlock/... never collide, even though
both clusters publish to the same Redis PUBLISH channel namespace. The fallbackTimeout and
lockTtl defaults are left in place since in-cluster Redis latency is well under 3 s:
# staging erpc.yamldatabase: sharedState: # distinct from the production clusterKey — same Redis, no cross-contamination clusterKey: "staging" connector: driver: redis redis: uri: "${SHARED_STATE_REDIS_URL}"---# production erpc.yamldatabase: sharedState: clusterKey: "prod" connector: driver: redis redis: uri: "${SHARED_STATE_REDIS_URL}"4. DynamoDB for AWS-only deployments. When Redis or PostgreSQL aren't available, DynamoDB
is a valid fallback, but PublishCounterInt64 is a no-op — propagation relies entirely on
polling. Cross-pod convergence latency is bounded by statePollInterval (default 5 s) rather
than a push event. Acceptable for batch indexing workloads that tolerate slightly stale
block-height views, but not recommended for latency-sensitive RPC fleets:
database: sharedState: clusterKey: "prod-main" connector: driver: dynamodb dynamodb: region: "us-east-1" table: "erpc-shared-state" # DynamoDB has no pub/sub — all propagation is via polling; # lower values cost more DynamoDB read capacity; default 5s is the floor statePollInterval: 5s5. No shared state (single-pod or dev). Omitting the block entirely gives you a silent per-process in-memory registry — no config needed, same API, no cross-pod propagation:
# No database.sharedState block — eRPC synthesises an in-memory registry automatically
database: {}Request/response behavior
The shared-state layer has no effect on JSON-RPC request or response shapes visible to clients. Its impact is routing-internal:
- Block-number reads (
latestBlock,finalizedBlock) used by the upstream selector and tag-aware routing come from the in-process atomic copy — zero latency on the request path. - Served-finalized counter (
network/<project>/<network>/finalizedBlock) is raised when an instance computes a higher network-widefinalized; the head computation reads the in-process copy and never waits on the store. Other instances serve it only withinmaxRegressionBlocksof their own tip and while one of their own voters reports it finalized, so a value no voter backs is ignored. Only raises are published. - Block-number refreshes are debounced via
TryUpdateIfStale; the foreground caller waits at mostupdateMaxWait(default 50 ms). If the refresh completes within that window the fresh value influences the current request; otherwise the stale value is used and the refresh completes asynchronously for the next request. Afinalized-tag request at its idle edge is the exception: it waits for the finalized refresh up to W. - A broken or unreachable shared-state connector degrades to per-pod local state — no error is returned to the client; routing quality degrades silently.
- Operator cordons (
erpc_cordonUpstream) are one more shared counter per upstream (operatorCordon, value = cordon time in unix-ms,0= lifted), with the same propagation, bootstrap fetch and local-first semantics as the block counters. See Cordoning.
Best practices
- Always set a unique
clusterKeyper logical deployment (e.g."prod-v2","staging"). The default"erpc-default"is shared by every unconfigured instance pointing at the same store — cross-contamination breaks block-height monotonicity. - Always set
connector.idexplicitly (id: "shared-state-redis"). The auto-assigned id for shared-state connectors isstring(driver)(e.g."redis"), not the"shared-state-redis"pattern used by cache connectors — copying connector blocks between scopes silently changes the id. - Prefer Redis or PostgreSQL over DynamoDB for shared state. DynamoDB has no pub/sub; convergence latency is 5 s by default vs. milliseconds for Redis pubsub.
- Do not set
fallbackTimeoutlow to try to speed up request latency —fallbackTimeoutgoverns background I/O only. The foreground latency knob isupdateMaxWait. - Keep
lockTtl > fallbackTimeout(the default 4 s > 3 s relationship). Validation enforces this, but understand it:lockTtlmust be long enough for one full Get+Set cycle under the lock. - In AWS deployments with Redis, set
pool_sizein the Redis URI (?pool_size=10) to avoid connection exhaustion under bursty background push traffic. - Monitor
erpc_upstream_block_head_large_rollbackin production. A sustained non-zero value means an upstream is experiencing genuine reorgs or provider resets — something worth routing around. A provider flapping between a healthy node and one stuck far behind no longer sets it: its drops never apply.
Edge cases & gotchas
Value=0is valid,UpdatedAt=0is not. Genesis block (earliestBlock=0) is a legitimate counter value. Uninitialised state is signalled byUpdatedAt <= 0, never byValue == 0. Source:data/connector.go:L32-34- Stale lock expiry is silent. If a remote operation takes longer than
lockTtl, the lock key expires.Unlockreturns "lock was already expired" logged at DEBUG — expected and benign. SettinglockTtl < fallbackTimeoutis rejected by validation to prevent this in every call. Source:common/validation.go:L292-295 - Remote-ahead adoption happens in background. A fresh pod can have its local counter bumped forward by a peer before the pod's first RPC poll completes. Source:
data/shared_state_variable.go:L686-691 - Small-rollback rejection advances local timestamp. When a remote update has a newer
UpdatedAtbut a value within the rollback threshold,advanceTimestampPastis called to ensure the local (higher) value wins the next reconciliation. Source:data/shared_state_variable.go:L211-218 TryUpdatemust not acquireupdateMu. HoldingupdateMuduringTryUpdatewould block a concurrentTryUpdateIfStalecaller already holding the mutex that could in turn wait for background operations — a classic deadlock. The old design had this bug; testTestLockOrderSimulation_OldBuggyBehaviordocuments it. Source:data/shared_state_variable_deadlock_test.go:L95-212continueAsyncRefreshgoroutine leak protection. If the downstream RPC ignores its context and never returns, a bounded timer offallbackTimeout + 1skills the helper. Source:data/shared_state_variable.go:L511- Double-checked staleness.
TryUpdateIfStalechecksIsStalebefore and after acquiringupdateMuto prevent a thundering herd where N goroutines all see stale and all fire a refresh after the first one already updated the value. Source:data/shared_state_variable.go:L399-413 - Redis pubsub channel buffered=1, drop-on-full. If the registry consumer falls behind, messages are silently dropped. The 5-minute periodic
pollingLoopis the recovery path. Source:data/redis_pubsub_manager.go:L450-457 - DynamoDB
PublishCounterInt64is a no-op. Cross-pod latency is bounded bystatePollInterval(default 5 s). Using DynamoDB means significantly higher convergence latency than Redis or PostgreSQL. Source:data/dynamodb.go:L825-829 WatchCounterInt64channel closed on shutdown.RedisPubSubManager.stop()closes all subscriber channels on app shutdown; the registry goroutine observing the channel receivesok=falseand marks the watch task as failed. This is benign since the app context is also cancelled at that point. Source:data/shared_state_registry.go:L200-207clusterKeypropagation order. Explicitdatabase.sharedState.clusterKeywins over the inherited top-levelclusterKey. Source:common/defaults.go:L803-805- The served tip is per instance. Pods compute it from the same shared per-upstream heads; nothing about the pick itself is stored. Source:
erpc/networks.go:L55-78 - init.go treats shared-state failure as a warning, not fatal. A broken Redis config produces degraded per-pod behavior, not a hard startup failure. Source:
erpc/init.go:L93-97 database.sharedStatenil meansSetDefaultsis never called on it. The synthesised config aterpc/erpc.go:L49-L66exists only in local scope and does not write back tocfg.Database.SharedState. Source:common/defaults.go:L837-840- Connector
idauto-assignment differs between shared-state and other scopes. Shared-state connector id defaults tostring(driver)(e.g."redis"), not"shared-state-redis". Source:common/defaults.go:L799-801
Observability
| Metric | Type | Labels | When it fires |
|---|---|---|---|
erpc_upstream_latest_block_number | Gauge | project, vendor, network, upstream | On every OnValue callback from latestBlockShared counter via tracker.SetLatestBlockNumber |
erpc_upstream_finalized_block_number | Gauge | project, vendor, network, upstream | On every OnValue callback from finalizedBlockShared counter via tracker.SetFinalizedBlockNumber |
erpc_upstream_latest_block_polled_total | Counter | project, vendor, network, upstream | Each time PollLatestBlockNumber issues an actual RPC call (not debounced by TryUpdateIfStale) |
erpc_upstream_finalized_block_polled_total | Counter | project, vendor, network, upstream | Each time PollFinalizedBlockNumber issues an actual RPC call |
erpc_upstream_block_head_large_rollback | Gauge | project, vendor, network, upstream, finality | On OnLargeRollback callback; value is the rollback size in blocks. finality is the head axis that rolled back (latest or finalized), not a DataFinalityState. Sticky: never reset, so it holds the last rollback size for the life of the process |
erpc_shared_counter_remote_rejected_total | Counter | reason | A remote counter state this instance rejected: non_positive (0 or less on a head counter), small_rollback (a drop within ignoreRollbackOf), held_rollback (a larger drop held until its lower side moves), stale_timestamp (an older large drop on a head counter, any older change on the others), malformed (a parse error or t<=0 at a connector or the initial fetch, once per distinct payload in a row per key; a record with no value is missing, not malformed) |
erpc_unexpected_panic_total | Counter | component, context, fingerprint | On panic recovery inside initCounterSync (component="shared-state-counter-sync"), messageLoop (component="redis-pubsub-message-loop"), or pollingLoop (component="redis-pubsub-polling-loop") |
OTel trace spans:
CounterInt64.TryUpdate— every foreground local-update call; attributes:key,new_value(when detailed tracing enabled).CounterInt64.TryUpdateIfStale— attributes:key,staleness_ms,skipped_not_stale,skipped_not_stale_after_mutex,role,result_path(got_resultvstimeout),async_refresh,foreground_remote_io_disabled.CounterInt64.TryUpdateIfStale.AcquireMutex— measures mutex wait time insideTryUpdateIfStale.CounterInt64.TryUpdateIfStale.ExecuteRefresh— spans the actual RPC call inside the refresh goroutine; attributetimeout_ms.RedisConnector.PublishCounterInt64— spans each publish call; attributeskey,value,updated_at,updated_by(when detailed tracing enabled).PostgreSQLConnector.PublishCounterInt64— same pattern as Redis span above.
Key log messages (component sharedState):
"no remote initial value found for counter"(DEBUG) — normal first-run; counter seeds from zero."fetched initial value for counter"(DEBUG) — successful seed from remote on first bootstrap."received new value from shared state sync"(DEBUG) — pubsub/watch update received and processed."failed to setup counter sync"(ERROR) — watch setup failed; will retry via initializer."lock held by another instance, proceeding with local lock"(DEBUG) — normal under load; background push proceeds without distributed lock."lock acquisition timed out waiting for other instance"(WARN) — lock contention abovelockMaxWait; falling back to publish-only propagation."lock expired during operations (expected behavior)"(DEBUG) — expected when operations take nearlockTtl."published counter value to remote"(DEBUG) — successful remote publish."pubsub reconnected successfully"(INFO) — Redis pubsub recovery."counter value increased (remote)"(TRACE) — value advanced by remote pubsub event."small rollback ignored (remote)"(TRACE) — rollback inside threshold, discarded."large rollback applied (remote)"(TRACE) — real reorg or provider reset accepted."large rollback held until the lower head moves (remote)"/(local)(DEBUG) — a drop beyond the threshold waits for a second, higher reading.
Source code entry points
data/shared_state_registry.go:L38-L80(opens in a new tab) —NewSharedStateRegistry: registry construction,clusterKeyprefix,sync.Mapof live countersdata/shared_state_variable.go:L364-L707(opens in a new tab) —TryUpdate,TryUpdateIfStale,scheduleBackgroundPushCurrent, lock ordering, rollback handling, timestamp CAS helpersdata/shared_state_registry.go:L102-L222(opens in a new tab) —GetCounterInt64: idempotent counter creation, bootstrap tasks,WatchCounterInt64goroutinedata/redis_pubsub_manager.go:L155-L460(opens in a new tab) —RedisPubSubManager: singlePSubscribe("counter:*"), reconnect backoff, periodic poll fallback, copy-on-write subscriber sliceerpc/erpc.go:L49-L66(opens in a new tab) — in-memory fallback synthesis whensharedState == nilcommon/defaults.go:L789-L828(opens in a new tab) —SharedStateConfig.SetDefaults; all default values including connector id overridedata/shared_state_sources.go(opens in a new tab) — named constants for thesourcefield in logs and traces (UpdateSourceRemoteSync,UpdateSourceTryUpdate, etc.)data/shared_state_variable_deadlock_test.go:L95-L212(opens in a new tab) — lock-ordering correctness tests
Related pages
- Connectors — the backing store options (Redis, PostgreSQL, DynamoDB, memory) that power shared state.
- Tag-aware routing —
use-upstreamselectors scope the served tip to the matched upstreams; no shared counter is involved. - EVM state poller — the consumer that calls
TryUpdateIfStaleper upstream; registersOnValue/OnLargeRollbackcallbacks. - Survive provider outages — routing quality under failover depends on consistent block-height awareness across pods.
- Deployment — horizontal scaling guidance where shared state becomes necessary.