diff --git a/docker/monitornode/dashboards/cryptosim-dashboard.json b/docker/monitornode/dashboards/cryptosim-dashboard.json index 288035db07..5a00b5cd02 100644 --- a/docker/monitornode/dashboards/cryptosim-dashboard.json +++ b/docker/monitornode/dashboards/cryptosim-dashboard.json @@ -3010,6 +3010,812 @@ ], "title": "Receipt Errors", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 282, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_read_duration_seconds_bucket[$__rate_interval]))", + "legendFormat": "p99", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cryptosim_receipt_read_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_read_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p50", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_read_duration_seconds_sum[$__rate_interval]) / rate(cryptosim_receipt_read_duration_seconds_count[$__rate_interval])", + "instant": false, + "legendFormat": "average", + "range": true, + "refId": "D" + } + ], + "title": "Receipt Read Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 283, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_reads_total[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Receipt Reads/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 284, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_cache_hits_total[$__rate_interval]) / (rate(cryptosim_receipt_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_cache_misses_total[$__rate_interval]))", + "legendFormat": "cache hit %", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_cache_misses_total[$__rate_interval]) / (rate(cryptosim_receipt_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_cache_misses_total[$__rate_interval]))", + "instant": false, + "legendFormat": "cache miss %", + "range": true, + "refId": "B" + } + ], + "title": "Receipt Cache Hit/Miss %", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 288, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_cache_hits_total[$__rate_interval]) / (rate(cryptosim_receipt_log_filter_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_log_filter_cache_miss_total[$__rate_interval]))", + "legendFormat": "cache hit %", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_cache_miss_total[$__rate_interval]) / (rate(cryptosim_receipt_log_filter_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_log_filter_cache_miss_total[$__rate_interval]))", + "instant": false, + "legendFormat": "cache miss %", + "range": true, + "refId": "B" + } + ], + "title": "Log Filter Cache Hit/Miss %", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 286, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_log_filter_duration_seconds_bucket[$__rate_interval]))", + "legendFormat": "p99", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cryptosim_receipt_log_filter_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_log_filter_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p50", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_duration_seconds_sum[$__rate_interval]) / rate(cryptosim_receipt_log_filter_duration_seconds_count[$__rate_interval])", + "instant": false, + "legendFormat": "average", + "range": true, + "refId": "D" + } + ], + "title": "Log Filter Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 287, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_duration_seconds_count[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Log Reads/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 289, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_log_filter_logs_returned_bucket[$__rate_interval]))", + "legendFormat": "p99", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_log_filter_logs_returned_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p50", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_logs_returned_sum[$__rate_interval]) / rate(cryptosim_receipt_log_filter_logs_returned_count[$__rate_interval])", + "instant": false, + "legendFormat": "average", + "range": true, + "refId": "C" + } + ], + "title": "Logs Returned Per Query", + "type": "timeseries" } ], "title": "Receipts", diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index 64a2994aa5..eae1d1d4ed 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -28,9 +28,10 @@ type cachedReceiptStore struct { cacheRotateInterval uint64 cacheNextRotate uint64 cacheMu sync.Mutex + readObserver ReceiptReadObserver } -func newCachedReceiptStore(backend ReceiptStore) ReceiptStore { +func newCachedReceiptStore(backend ReceiptStore, observer ReceiptReadObserver) ReceiptStore { if backend == nil { return nil } @@ -44,6 +45,7 @@ func newCachedReceiptStore(backend ReceiptStore) ReceiptStore { backend: backend, cache: newLedgerCache(), cacheRotateInterval: interval, + readObserver: observer, } if provider, ok := backend.(cacheWarmupProvider); ok { store.cacheReceipts(provider.warmupReceipts()) @@ -51,6 +53,26 @@ func newCachedReceiptStore(backend ReceiptStore) ReceiptStore { return store } +// StableReceiptCacheWindowBlocks returns the near-tip block window that is +// guaranteed to stay in the active write chunk until the next rotation. +func StableReceiptCacheWindowBlocks(store ReceiptStore) uint64 { + cached, ok := store.(*cachedReceiptStore) + if !ok || cached.cacheRotateInterval == 0 { + return 0 + } + return cached.cacheRotateInterval +} + +// EstimatedReceiptCacheWindowBlocks returns the approximate recent block window +// normally served by the in-memory receipt cache (current chunk + previous one). +func EstimatedReceiptCacheWindowBlocks(store ReceiptStore) uint64 { + hotWindow := StableReceiptCacheWindowBlocks(store) + if hotWindow == 0 { + return 0 + } + return hotWindow * uint64(numCacheChunks-1) +} + func (s *cachedReceiptStore) LatestVersion() int64 { return s.backend.LatestVersion() } @@ -65,15 +87,19 @@ func (s *cachedReceiptStore) SetEarliestVersion(version int64) error { func (s *cachedReceiptStore) GetReceipt(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) { if receipt, ok := s.cache.GetReceipt(txHash); ok { + s.reportCacheHit() return receipt, nil } + s.reportCacheMiss() return s.backend.GetReceipt(ctx, txHash) } func (s *cachedReceiptStore) GetReceiptFromStore(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) { if receipt, ok := s.cache.GetReceipt(txHash); ok { + s.reportCacheHit() return receipt, nil } + s.reportCacheMiss() return s.backend.GetReceiptFromStore(ctx, txHash) } @@ -99,8 +125,10 @@ func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint // Merge results, avoiding duplicates by tracking seen (blockNum, txIndex, logIndex) if len(cacheLogs) == 0 { + s.reportLogFilterCacheMiss() return backendLogs, nil } + s.reportLogFilterCacheHit() if len(backendLogs) == 0 { sortLogs(cacheLogs) return cacheLogs, nil @@ -220,3 +248,27 @@ func (s *cachedReceiptStore) maybeRotateCacheLocked(blockNumber uint64) { s.cacheNextRotate += s.cacheRotateInterval } } + +func (s *cachedReceiptStore) reportCacheHit() { + if s.readObserver != nil { + s.readObserver.ReportReceiptCacheHit() + } +} + +func (s *cachedReceiptStore) reportCacheMiss() { + if s.readObserver != nil { + s.readObserver.ReportReceiptCacheMiss() + } +} + +func (s *cachedReceiptStore) reportLogFilterCacheHit() { + if s.readObserver != nil { + s.readObserver.ReportLogFilterCacheHit() + } +} + +func (s *cachedReceiptStore) reportLogFilterCacheMiss() { + if s.readObserver != nil { + s.readObserver.ReportLogFilterCacheMiss() + } +} diff --git a/sei-db/ledger_db/receipt/cached_receipt_store_test.go b/sei-db/ledger_db/receipt/cached_receipt_store_test.go index aed0e89f78..c392e3fce4 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store_test.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store_test.go @@ -18,6 +18,29 @@ type fakeReceiptBackend struct { filterLogCalls int } +type fakeReceiptReadObserver struct { + cacheHits int + cacheMisses int + logFilterCacheHits int + logFilterCacheMisses int +} + +func (f *fakeReceiptReadObserver) ReportReceiptCacheHit() { + f.cacheHits++ +} + +func (f *fakeReceiptReadObserver) ReportReceiptCacheMiss() { + f.cacheMisses++ +} + +func (f *fakeReceiptReadObserver) ReportLogFilterCacheHit() { + f.logFilterCacheHits++ +} + +func (f *fakeReceiptReadObserver) ReportLogFilterCacheMiss() { + f.logFilterCacheMisses++ +} + func newFakeReceiptBackend() *fakeReceiptBackend { return &fakeReceiptBackend{ receipts: make(map[common.Hash]*types.Receipt), @@ -70,7 +93,7 @@ func (f *fakeReceiptBackend) Close() error { func TestCachedReceiptStoreUsesCacheForReceipt(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() - store := newCachedReceiptStore(backend) + store := newCachedReceiptStore(backend, nil) txHash := common.HexToHash("0x1") addr := common.HexToAddress("0x100") @@ -89,7 +112,7 @@ func TestCachedReceiptStoreUsesCacheForReceipt(t *testing.T) { func TestCachedReceiptStoreFilterLogsDelegates(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() - store := newCachedReceiptStore(backend) + store := newCachedReceiptStore(backend, nil) txHash := common.HexToHash("0x2") addr := common.HexToAddress("0x200") @@ -124,7 +147,7 @@ func TestCachedReceiptStoreFilterLogsReturnsSortedLogs(t *testing.T) { Index: 0, }, } - store := newCachedReceiptStore(backend) + store := newCachedReceiptStore(backend, nil) receiptA := makeTestReceipt(common.HexToHash("0xa"), 11, 1, common.HexToAddress("0x210"), []common.Hash{common.HexToHash("0x1")}) receiptB := makeTestReceipt(common.HexToHash("0xb"), 11, 0, common.HexToAddress("0x220"), []common.Hash{common.HexToHash("0x2")}) @@ -143,3 +166,36 @@ func TestCachedReceiptStoreFilterLogsReturnsSortedLogs(t *testing.T) { require.Equal(t, uint(1), logs[2].TxIndex) require.Equal(t, uint64(12), logs[3].BlockNumber) } + +func TestCachedReceiptStoreReportsCacheHit(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + observer := &fakeReceiptReadObserver{} + store := newCachedReceiptStore(backend, observer) + + txHash := common.HexToHash("0x10") + receipt := makeTestReceipt(txHash, 7, 1, common.HexToAddress("0x100"), nil) + + require.NoError(t, store.SetReceipts(ctx, []ReceiptRecord{{TxHash: txHash, Receipt: receipt}})) + + backend.getReceiptCalls = 0 + got, err := store.GetReceipt(ctx, txHash) + require.NoError(t, err) + require.Equal(t, receipt.TxHashHex, got.TxHashHex) + require.Equal(t, 0, backend.getReceiptCalls) + require.Equal(t, 1, observer.cacheHits) + require.Equal(t, 0, observer.cacheMisses) +} + +func TestCachedReceiptStoreReportsCacheMiss(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + observer := &fakeReceiptReadObserver{} + store := newCachedReceiptStore(backend, observer) + + _, err := store.GetReceipt(ctx, common.HexToHash("0x404")) + require.ErrorIs(t, err, ErrNotFound) + require.Equal(t, 1, backend.getReceiptCalls) + require.Equal(t, 0, observer.cacheHits) + require.Equal(t, 1, observer.cacheMisses) +} diff --git a/sei-db/ledger_db/receipt/parquet_store.go b/sei-db/ledger_db/receipt/parquet_store.go index bdf88b2fd0..1822c46432 100644 --- a/sei-db/ledger_db/receipt/parquet_store.go +++ b/sei-db/ledger_db/receipt/parquet_store.go @@ -90,6 +90,9 @@ func (s *parquetReceiptStore) GetReceipt(ctx sdk.Context, txHash common.Hash) (* return receipt, nil } + if s.storeKey == nil { + return nil, ErrNotFound + } store := ctx.KVStore(s.storeKey) bz := store.Get(types.ReceiptKey(txHash)) if bz == nil { diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index 37276b0113..33f568bb2c 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -53,6 +53,15 @@ type ReceiptRecord struct { ReceiptBytes []byte // Optional pre-marshaled receipt (must match Receipt if set) } +// ReceiptReadObserver receives callbacks when cached receipt lookups either hit +// the in-memory ledger cache or fall through to the backend store. +type ReceiptReadObserver interface { + ReportReceiptCacheHit() + ReportReceiptCacheMiss() + ReportLogFilterCacheHit() + ReportLogFilterCacheMiss() +} + type receiptStore struct { db seidbtypes.StateStore storeKey sdk.StoreKey @@ -78,11 +87,21 @@ func normalizeReceiptBackend(backend string) string { } func NewReceiptStore(config dbconfig.ReceiptStoreConfig, storeKey sdk.StoreKey) (ReceiptStore, error) { + return NewReceiptStoreWithReadObserver(config, storeKey, nil) +} + +// NewReceiptStoreWithReadObserver constructs a receipt store and optionally +// reports cache hits/misses for receipt-by-hash reads via observer callbacks. +func NewReceiptStoreWithReadObserver( + config dbconfig.ReceiptStoreConfig, + storeKey sdk.StoreKey, + observer ReceiptReadObserver, +) (ReceiptStore, error) { backend, err := newReceiptBackend(config, storeKey) if err != nil { return nil, err } - return newCachedReceiptStore(backend), nil + return newCachedReceiptStore(backend, observer), nil } // BackendTypeName returns the backend implementation name ("parquet" or "pebble") for testing. diff --git a/sei-db/state_db/bench/cryptosim/canned_random.go b/sei-db/state_db/bench/cryptosim/canned_random.go index 6e95db15e4..7e0e37cba5 100644 --- a/sei-db/state_db/bench/cryptosim/canned_random.go +++ b/sei-db/state_db/bench/cryptosim/canned_random.go @@ -91,6 +91,9 @@ func (cr *CannedRandom) Bytes(count int) []byte { // Returns a slice of random bytes from a given seed. Bytes are deterministic given the same seed. // +// Unlike most CannedRandom methods, SeededBytes is safe for concurrent use: it only reads +// from the immutable buffer and does not advance the internal index. +// // Returned slice is NOT safe to modify. If modification is required, the caller should make a copy of the slice. func (cr *CannedRandom) SeededBytes(count int, seed int64) []byte { if count < 0 { diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index dbb621e8ae..d71c038376 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -1,8 +1,15 @@ { - "Comment": "For testing with the state store and reciept store both enabled.", + "Comment": "For testing with the state store and receipt store both enabled, with concurrent reads, pruning, and cache.", "DataDir": "data", + "LogDir": "logs", + "LogLevel": "info", "MinimumNumberOfColdAccounts": 1000000, "MinimumNumberOfDormantAccounts": 1000000, - "GenerateReceipts": true + "GenerateReceipts": true, + "ReceiptReadConcurrency": 4, + "ReceiptReadsPerSecond": 1000, + "ReceiptColdReadRatio": 0.1, + "LogFilterReadConcurrency": 4, + "LogFilterReadsPerSecond": 200, + "LogFilterColdReadRatio": 0.1 } - diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index 53b5f28e65..83111c43b8 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -158,6 +158,41 @@ type CryptoSimConfig struct { // If greater than 0, the benchmark will throttle the transaction rate to this value, in hertz. MaxTPS float64 + // Number of concurrent reader goroutines issuing receipt lookups. 0 disables reads. + ReceiptReadConcurrency int + + // Target total receipt reads per second across all reader goroutines. + // Reads are distributed evenly across readers. + ReceiptReadsPerSecond int + + // Fraction of single-receipt reads that intentionally target blocks older + // than the in-memory receipt-cache window (0.0-1.0). + // These reads should mostly miss cache and fall through to DuckDB. + ReceiptColdReadRatio float64 + + // Deprecated: ignored when LogFilterReadConcurrency > 0. Previously controlled + // the fraction of shared reads that were log filters vs receipt lookups. + ReceiptLogFilterRatio float64 + + // Number of concurrent goroutines issuing log filter (eth_getLogs) queries. 0 disables log filter reads. + // These goroutines are independent from the receipt reader goroutines. + LogFilterReadConcurrency int + + // Target total log filter reads per second across all log filter goroutines. + LogFilterReadsPerSecond int + + // Fraction of log filter reads that intentionally target blocks older than + // the in-memory cache window (0.0-1.0). These cold reads miss the cache + // and fall through to DuckDB on closed parquet files. The remaining + // (1 - LogFilterColdReadRatio) reads target recent blocks likely in cache. + // Default 0.1 yields ~90% cache hit ratio. + LogFilterColdReadRatio float64 + + // Exponent controlling the recency bias within the chosen hot/cold read range. + // 1.0 = uniform within that range; higher values skew reads toward the newest + // blocks in the selected bucket. + ReceiptReadRecencyExponent float64 + // Number of recent blocks to keep before pruning parquet files. 0 disables pruning. ReceiptKeepRecent int64 @@ -215,6 +250,14 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { RecieptChannelCapacity: 32, DisableTransactionExecution: false, MaxTPS: 0, + ReceiptReadConcurrency: 0, + ReceiptReadsPerSecond: 100, + ReceiptColdReadRatio: 0.1, + ReceiptLogFilterRatio: 0, + LogFilterReadConcurrency: 0, + LogFilterReadsPerSecond: 100, + LogFilterColdReadRatio: 0.1, + ReceiptReadRecencyExponent: 3.0, ReceiptKeepRecent: 100_000, ReceiptPruneIntervalSeconds: 600, LogLevel: "info", @@ -302,12 +345,29 @@ func (c *CryptoSimConfig) Validate() error { if c.MaxTPS < 0 { return fmt.Errorf("MaxTPS must be non-negative (got %f)", c.MaxTPS) } + if c.ReceiptColdReadRatio < 0 || c.ReceiptColdReadRatio > 1 { + return fmt.Errorf("ReceiptColdReadRatio must be in [0, 1] (got %f)", c.ReceiptColdReadRatio) + } + if c.ReceiptLogFilterRatio < 0 || c.ReceiptLogFilterRatio > 1 { + return fmt.Errorf("ReceiptLogFilterRatio must be in [0, 1] (got %f)", c.ReceiptLogFilterRatio) + } + if c.LogFilterReadConcurrency < 0 { + return fmt.Errorf("LogFilterReadConcurrency must be non-negative (got %d)", c.LogFilterReadConcurrency) + } + if c.LogFilterColdReadRatio < 0 || c.LogFilterColdReadRatio > 1 { + return fmt.Errorf("LogFilterColdReadRatio must be in [0, 1] (got %f)", c.LogFilterColdReadRatio) + } + if c.ReceiptReadConcurrency < 0 { + return fmt.Errorf("ReceiptReadConcurrency must be non-negative (got %d)", c.ReceiptReadConcurrency) + } + if c.ReceiptReadRecencyExponent < 1 { + return fmt.Errorf("ReceiptReadRecencyExponent must be >= 1.0 (got %f)", c.ReceiptReadRecencyExponent) + } switch strings.ToLower(c.LogLevel) { case "debug", "info", "warn", "error": default: return fmt.Errorf("LogLevel must be one of debug, info, warn, error (got %q)", c.LogLevel) } - return nil } diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go index 050a8f6772..7d8a2531b4 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go @@ -25,6 +25,12 @@ var receiptWriteLatencyBuckets = []float64{ 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, } +var receiptReadLatencyBuckets = []float64{ + 0.00001, 0.00005, 0.0001, 0.00025, 0.0005, + 0.001, 0.0025, 0.005, 0.01, 0.025, + 0.05, 0.1, 0.25, 0.5, 1, +} + // CryptosimMetrics holds OpenTelemetry metrics for the cryptosim benchmark. // Metrics are exported via whatever exporter is configured on the global OTel // MeterProvider (e.g., Prometheus, OTLP). This package does not import Prometheus. @@ -49,10 +55,20 @@ type CryptosimMetrics struct { uptimeSeconds metric.Float64Gauge // Receipt metrics - receiptBlockWriteDuration metric.Float64Histogram - receiptChannelDepth metric.Int64Gauge - receiptsWrittenTotal metric.Int64Counter - receiptErrorsTotal metric.Int64Counter + receiptBlockWriteDuration metric.Float64Histogram + receiptChannelDepth metric.Int64Gauge + receiptsWrittenTotal metric.Int64Counter + receiptErrorsTotal metric.Int64Counter + receiptReadDuration metric.Float64Histogram + receiptReadsTotal metric.Int64Counter + receiptCacheHitsTotal metric.Int64Counter + receiptCacheMissesTotal metric.Int64Counter + receiptReadsFoundTotal metric.Int64Counter + receiptReadsNotFoundTotal metric.Int64Counter + receiptLogFilterDuration metric.Float64Histogram + receiptLogFilterCacheHitsTotal metric.Int64Counter + receiptLogFilterCacheMissTotal metric.Int64Counter + receiptLogFilterLogsReturned metric.Int64Histogram mainThreadPhase *metrics.PhaseTimer transactionPhaseTimerFactory *metrics.PhaseTimerFactory @@ -179,6 +195,58 @@ func NewCryptosimMetrics( metric.WithDescription("Total receipt processing errors (marshal or write failures)"), metric.WithUnit("{count}"), ) + receiptReadDuration, _ := meter.Float64Histogram( + "cryptosim_receipt_read_duration_seconds", + metric.WithDescription("End-to-end receipt read latency (includes cache layer)"), + metric.WithExplicitBucketBoundaries(receiptReadLatencyBuckets...), + metric.WithUnit("s"), + ) + receiptReadsTotal, _ := meter.Int64Counter( + "cryptosim_receipt_reads_total", + metric.WithDescription("Total receipt read attempts"), + metric.WithUnit("{count}"), + ) + receiptCacheHitsTotal, _ := meter.Int64Counter( + "cryptosim_receipt_cache_hits_total", + metric.WithDescription("Receipt reads served from the ledger cache"), + metric.WithUnit("{count}"), + ) + receiptCacheMissesTotal, _ := meter.Int64Counter( + "cryptosim_receipt_cache_misses_total", + metric.WithDescription("Receipt reads that missed the in-memory ledger cache and fell through to the backend"), + metric.WithUnit("{count}"), + ) + receiptReadsFoundTotal, _ := meter.Int64Counter( + "cryptosim_receipt_reads_found_total", + metric.WithDescription("Receipt reads that returned a receipt"), + metric.WithUnit("{count}"), + ) + receiptReadsNotFoundTotal, _ := meter.Int64Counter( + "cryptosim_receipt_reads_not_found_total", + metric.WithDescription("Receipt reads that returned no receipt because the hash was absent or pruned"), + metric.WithUnit("{count}"), + ) + receiptLogFilterDuration, _ := meter.Float64Histogram( + "cryptosim_receipt_log_filter_duration_seconds", + metric.WithDescription("DuckDB eth_getLogs filter query latency"), + metric.WithUnit("s"), + ) + receiptLogFilterCacheHitsTotal, _ := meter.Int64Counter( + "cryptosim_receipt_log_filter_cache_hits_total", + metric.WithDescription("Log filter queries where the in-memory cache contributed results"), + metric.WithUnit("{count}"), + ) + receiptLogFilterCacheMissTotal, _ := meter.Int64Counter( + "cryptosim_receipt_log_filter_cache_miss_total", + metric.WithDescription("Log filter queries served entirely from the backend (cache contributed nothing)"), + metric.WithUnit("{count}"), + ) + receiptLogFilterLogsReturned, _ := meter.Int64Histogram( + "cryptosim_receipt_log_filter_logs_returned", + metric.WithDescription("Number of log entries returned per FilterLogs query"), + metric.WithExplicitBucketBoundaries(0, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000), + metric.WithUnit("{count}"), + ) mainThreadPhase := dbPhaseTimer if mainThreadPhase == nil { @@ -188,29 +256,39 @@ func NewCryptosimMetrics( transactionPhaseTimerFactory := metrics.NewPhaseTimerFactory(meter, "transaction") m := &CryptosimMetrics{ - ctx: ctx, - blocksFinalizedTotal: blocksFinalizedTotal, - transactionsProcessedTotal: transactionsProcessedTotal, - totalAccounts: totalAccounts, - hotAccounts: hotAccounts, - coldAccounts: coldAccounts, - dormantAccounts: dormantAccounts, - totalErc20Contracts: totalErc20Contracts, - dbCommitsTotal: dbCommitsTotal, - dataDirSizeBytes: dataDirSizeBytes, - dataDirAvailableBytes: dataDirAvailableBytes, - logDirSizeBytes: logDirSizeBytes, - processReadBytesTotal: processReadBytesTotal, - processWriteBytesTotal: processWriteBytesTotal, - processReadCountTotal: processReadCountTotal, - processWriteCountTotal: processWriteCountTotal, - uptimeSeconds: uptimeSeconds, - receiptBlockWriteDuration: receiptBlockWriteDuration, - receiptChannelDepth: receiptChannelDepth, - receiptsWrittenTotal: receiptsWrittenTotal, - receiptErrorsTotal: receiptErrorsTotal, - mainThreadPhase: mainThreadPhase, - transactionPhaseTimerFactory: transactionPhaseTimerFactory, + ctx: ctx, + blocksFinalizedTotal: blocksFinalizedTotal, + transactionsProcessedTotal: transactionsProcessedTotal, + totalAccounts: totalAccounts, + hotAccounts: hotAccounts, + coldAccounts: coldAccounts, + dormantAccounts: dormantAccounts, + totalErc20Contracts: totalErc20Contracts, + dbCommitsTotal: dbCommitsTotal, + dataDirSizeBytes: dataDirSizeBytes, + dataDirAvailableBytes: dataDirAvailableBytes, + logDirSizeBytes: logDirSizeBytes, + processReadBytesTotal: processReadBytesTotal, + processWriteBytesTotal: processWriteBytesTotal, + processReadCountTotal: processReadCountTotal, + processWriteCountTotal: processWriteCountTotal, + uptimeSeconds: uptimeSeconds, + receiptBlockWriteDuration: receiptBlockWriteDuration, + receiptChannelDepth: receiptChannelDepth, + receiptsWrittenTotal: receiptsWrittenTotal, + receiptErrorsTotal: receiptErrorsTotal, + receiptReadDuration: receiptReadDuration, + receiptReadsTotal: receiptReadsTotal, + receiptCacheHitsTotal: receiptCacheHitsTotal, + receiptCacheMissesTotal: receiptCacheMissesTotal, + receiptReadsFoundTotal: receiptReadsFoundTotal, + receiptReadsNotFoundTotal: receiptReadsNotFoundTotal, + receiptLogFilterDuration: receiptLogFilterDuration, + receiptLogFilterCacheHitsTotal: receiptLogFilterCacheHitsTotal, + receiptLogFilterCacheMissTotal: receiptLogFilterCacheMissTotal, + receiptLogFilterLogsReturned: receiptLogFilterLogsReturned, + mainThreadPhase: mainThreadPhase, + transactionPhaseTimerFactory: transactionPhaseTimerFactory, } if config.BackgroundMetricsScrapeInterval > 0 { @@ -484,6 +562,76 @@ func (m *CryptosimMetrics) ReportReceiptError() { m.receiptErrorsTotal.Add(context.Background(), 1) } +func (m *CryptosimMetrics) RecordReceiptReadDuration(seconds float64) { + if m == nil || m.receiptReadDuration == nil { + return + } + m.receiptReadDuration.Record(context.Background(), seconds) +} + +func (m *CryptosimMetrics) ReportReceiptRead() { + if m == nil || m.receiptReadsTotal == nil { + return + } + m.receiptReadsTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptCacheHit() { + if m == nil || m.receiptCacheHitsTotal == nil { + return + } + m.receiptCacheHitsTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptCacheMiss() { + if m == nil || m.receiptCacheMissesTotal == nil { + return + } + m.receiptCacheMissesTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptReadFound() { + if m == nil || m.receiptReadsFoundTotal == nil { + return + } + m.receiptReadsFoundTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptReadNotFound() { + if m == nil || m.receiptReadsNotFoundTotal == nil { + return + } + m.receiptReadsNotFoundTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) RecordReceiptLogFilterDuration(seconds float64) { + if m == nil || m.receiptLogFilterDuration == nil { + return + } + m.receiptLogFilterDuration.Record(context.Background(), seconds) +} + +func (m *CryptosimMetrics) ReportLogFilterCacheHit() { + if m == nil || m.receiptLogFilterCacheHitsTotal == nil { + return + } + m.receiptLogFilterCacheHitsTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportLogFilterCacheMiss() { + if m == nil || m.receiptLogFilterCacheMissTotal == nil { + return + } + m.receiptLogFilterCacheMissTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) RecordLogFilterLogsReturned(count int64) { + if m == nil || m.receiptLogFilterLogsReturned == nil { + return + } + m.receiptLogFilterLogsReturned.Record(context.Background(), count) +} + // startReceiptChannelDepthSampling periodically records the depth of the receipt channel. func (m *CryptosimMetrics) startReceiptChannelDepthSampling(ch <-chan *block, intervalSeconds int) { if m == nil || m.receiptChannelDepth == nil || intervalSeconds <= 0 || ch == nil { diff --git a/sei-db/state_db/bench/cryptosim/receipt.go b/sei-db/state_db/bench/cryptosim/receipt.go index 4ab348aa2c..60394b1086 100644 --- a/sei-db/state_db/bench/cryptosim/receipt.go +++ b/sei-db/state_db/bench/cryptosim/receipt.go @@ -32,6 +32,11 @@ const ( syntheticReceiptGasPriceSpan uint64 = 9_000_000_000 syntheticReceiptTransferBase uint64 = 1_000_000 syntheticReceiptTransferSpan uint64 = 10_000_000_000 + + // Multiplied by blockNumber then added to txIndex to produce a unique seed per + // transaction. Supports up to 1M txs per block before collisions. With int64, + // block numbers up to ~9.2 trillion are safe before overflow (~290k years at 1 block/sec). + syntheticTxIDBlockStride int64 = 1_000_000 ) var erc20TransferEventSignatureBytes = [hashLen]byte{ @@ -41,6 +46,27 @@ var erc20TransferEventSignatureBytes = [hashLen]byte{ 0x28, 0xf5, 0x5a, 0x4d, 0xf5, 0x23, 0xb3, 0xef, } +// SyntheticTxHash returns a deterministic 32-byte tx hash for a given (blockNumber, txIndex) pair. +// +// It uses CannedRandom.SeededBytes, which is a pure read from the pre-generated buffer — no +// internal state is advanced, and the result depends only on the CannedRandom's seed/buffer +// and the inputs. This means any goroutine with a CannedRandom created from the same +// (seed, bufferSize) can reconstruct any tx hash from just the block number and tx index, +// without storing the hashes. Readers use this to compute query targets on the fly: +// +// validRange = [max(1, latestBlock - keepRecent + 1), latestBlock] +// randomBlock = pick from validRange +// randomTxIdx = pick from [0, txsPerBlock) +// txHash = SyntheticTxHash(crand, randomBlock, randomTxIdx) +// +// The hash automatically becomes invalid (returns no result) once the corresponding +// parquet file is pruned, so readers never need to track which hashes are live. +func SyntheticTxHash(crand *CannedRandom, blockNumber uint64, txIndex uint32) []byte { + //nolint:gosec // block numbers and tx indices won't exceed int64 in benchmarks + txID := int64(blockNumber)*syntheticTxIDBlockStride + int64(txIndex) + return crand.SeededBytes(hashLen, txID) +} + // BuildERC20TransferReceiptFromTxn produces a plausible successful ERC20 transfer receipt from a transaction. func BuildERC20TransferReceiptFromTxn( crand *CannedRandom, @@ -137,7 +163,7 @@ func BuildERC20TransferReceipt( TxType: txType, CumulativeGasUsed: cumulativeGasUsed, ContractAddress: contractAddressHex, - TxHashHex: BytesToHex(crand.Bytes(hashLen)), + TxHashHex: BytesToHex(SyntheticTxHash(crand, blockNumber, txIndex)), GasUsed: gasUsed, EffectiveGasPrice: effectiveGasPrice, BlockNumber: blockNumber, diff --git a/sei-db/state_db/bench/cryptosim/receipt_test.go b/sei-db/state_db/bench/cryptosim/receipt_test.go index f1e61109fb..367264c094 100644 --- a/sei-db/state_db/bench/cryptosim/receipt_test.go +++ b/sei-db/state_db/bench/cryptosim/receipt_test.go @@ -82,6 +82,47 @@ func TestBuildERC20TransferReceipt_InvalidInputs(t *testing.T) { } } +func TestSyntheticTxHashDeterminism(t *testing.T) { + crand1 := NewCannedRandom(1<<20, 42) + crand2 := NewCannedRandom(1<<20, 42) + + block := uint64(500_000) + txIdx := uint32(7) + + hash1 := SyntheticTxHash(crand1, block, txIdx) + hash2 := SyntheticTxHash(crand2, block, txIdx) + + if len(hash1) != 32 { + t.Fatalf("expected 32 bytes, got %d", len(hash1)) + } + for i := range hash1 { + if hash1[i] != hash2[i] { + t.Fatal("same (seed, bufferSize, block, txIdx) must produce identical hashes") + } + } + + // Same call again on the same instance must be stable (SeededBytes is stateless). + hash3 := SyntheticTxHash(crand1, block, txIdx) + for i := range hash1 { + if hash1[i] != hash3[i] { + t.Fatal("repeated calls with same inputs must return identical hashes") + } + } + + // Different (block, txIdx) must produce a different hash. + other := SyntheticTxHash(crand1, block, txIdx+1) + same := true + for i := range hash1 { + if hash1[i] != other[i] { + same = false + break + } + } + if same { + t.Fatal("different (block, txIdx) should produce different hashes") + } +} + // Regression test: account keys with EVMKeyCode prefix must still be accepted. func TestBuildERC20TransferReceipt_EVMKeyCodeAccounts(t *testing.T) { crand := NewCannedRandom(1<<20, 42) diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index 309c03667e..9d7d22ccd3 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -3,11 +3,15 @@ package cryptosim import ( "context" "fmt" + "math" + "math/rand" "path/filepath" + "sync" "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/filters" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" dbconfig "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" @@ -15,8 +19,64 @@ import ( evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) -// A simulated receipt store using the real production receipt.ReceiptStore -// (cached parquet backend with WAL, flush, rotation, and pruning). +const ( + defaultTxHashRingSize = 1_000_000 +) + +// txHashEntry stores a written tx hash along with its block and contract address, +// used by reader goroutines to generate realistic log filter queries. +type txHashEntry struct { + txHash common.Hash + blockNumber uint64 + contractAddress common.Address +} + +// txHashRing is a fixed-size ring buffer of recently written tx hashes. +// Writers call Push from the main loop; readers call RandomEntry from goroutines. +type txHashRing struct { + mu sync.RWMutex + entries []txHashEntry + size int + head int + count int +} + +func newTxHashRing(size int) *txHashRing { + return &txHashRing{ + entries: make([]txHashEntry, size), + size: size, + } +} + +func (r *txHashRing) Push(txHash common.Hash, blockNumber uint64, contractAddress common.Address) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries[r.head] = txHashEntry{ + txHash: txHash, + blockNumber: blockNumber, + contractAddress: contractAddress, + } + r.head = (r.head + 1) % r.size + if r.count < r.size { + r.count++ + } +} + +// RandomEntry returns a random entry from the ring. The caller's rng is used +// to avoid contention on a shared rng across reader goroutines. +func (r *txHashRing) RandomEntry(rng *rand.Rand) *txHashEntry { + r.mu.RLock() + defer r.mu.RUnlock() + if r.count == 0 { + return nil + } + idx := rng.Intn(r.count) + entry := r.entries[idx] + return &entry +} + +// A simulated receipt store with concurrent reads, writes, and pruning +// backed by the production receipt.ReceiptStore (parquet + ledger cache). type RecieptStoreSimulator struct { ctx context.Context cancel context.CancelFunc @@ -25,12 +85,20 @@ type RecieptStoreSimulator struct { recieptsChan chan *block - store receipt.ReceiptStore - metrics *CryptosimMetrics + store receipt.ReceiptStore + crand *CannedRandom + txRing *txHashRing + metrics *CryptosimMetrics + receiptStableHotWindowBlocks uint64 + receiptCacheWindowBlocks uint64 } // Creates a new receipt store simulator backed by the production ReceiptStore -// (parquet backend + ledger cache), matching the real node write path. +// (parquet backend + ledger cache), with optional concurrent reader goroutines. +// +// Receipt-by-hash reads reconstruct tx hashes on the fly via SyntheticTxHash +// (no storage needed). Log filter reads use the ring buffer to sample contract +// addresses written by the write path. See SyntheticTxHash in receipt.go for details. func NewRecieptStoreSimulator( ctx context.Context, config *CryptoSimConfig, @@ -47,21 +115,42 @@ func NewRecieptStoreSimulator( } // nil StoreKey is safe: the parquet write path never touches the legacy KV store. - store, err := receipt.NewReceiptStore(storeCfg, nil) + // Cryptosim passes its metrics as a read observer so cache hits/misses are measured + // at the cache wrapper, which is the only layer that can distinguish them reliably. + store, err := receipt.NewReceiptStoreWithReadObserver(storeCfg, nil, metrics) if err != nil { cancel() return nil, fmt.Errorf("failed to create receipt store: %w", err) } + // CannedRandom with the same (seed, bufferSize) as the block builder so that + // SyntheticTxHash produces the same hashes the write path stored. + // Only SeededBytes is called, which is a read-only operation safe for concurrent use. + crand := NewCannedRandom(config.CannedRandomSize, config.Seed) + + txRing := newTxHashRing(defaultTxHashRingSize) + r := &RecieptStoreSimulator{ - ctx: derivedCtx, - cancel: cancel, - config: config, - recieptsChan: recieptsChan, - store: store, - metrics: metrics, + ctx: derivedCtx, + cancel: cancel, + config: config, + recieptsChan: recieptsChan, + store: store, + crand: crand, + txRing: txRing, + metrics: metrics, + receiptStableHotWindowBlocks: receipt.StableReceiptCacheWindowBlocks(store), + receiptCacheWindowBlocks: receipt.EstimatedReceiptCacheWindowBlocks(store), } go r.mainLoop() + + if config.ReceiptReadConcurrency > 0 && config.ReceiptReadsPerSecond > 0 { + r.startReceiptReaders() + } + if config.LogFilterReadConcurrency > 0 && config.LogFilterReadsPerSecond > 0 { + r.startLogFilterReaders() + } + return r, nil } @@ -82,13 +171,19 @@ func (r *RecieptStoreSimulator) mainLoop() { } // Processes a block of receipts using the production ReceiptStore.SetReceipts path, -// which writes to parquet (WAL + buffer + rotation) and populates the ledger cache. +// then populates the ring buffer with contract addresses for log filter reads. func (r *RecieptStoreSimulator) processBlock(blk *block) { blockNumber := uint64(blk.BlockNumber()) //nolint:gosec records := make([]receipt.ReceiptRecord, 0, len(blk.reciepts)) var marshalErrors int64 + type ringEntry struct { + txHash common.Hash + contractAddress common.Address + } + ringEntries := make([]ringEntry, 0, len(blk.reciepts)) + for _, rcpt := range blk.reciepts { if rcpt == nil { continue @@ -107,6 +202,11 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { Receipt: rcpt, ReceiptBytes: receiptBytes, }) + + ringEntries = append(ringEntries, ringEntry{ + txHash: txHash, + contractAddress: common.HexToAddress(rcpt.ContractAddress), + }) } for range marshalErrors { @@ -114,8 +214,6 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { } if len(records) > 0 { - // Build a minimal sdk.Context with the block height set. - // The parquet write path only uses ctx.BlockHeight() and ctx.Context(). sdkCtx := sdk.NewContext(nil, tmproto.Header{Height: int64(blockNumber)}, false) //nolint:gosec start := time.Now() @@ -128,11 +226,246 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { r.metrics.ReportReceiptsWritten(int64(len(records))) } + for _, entry := range ringEntries { + r.txRing.Push(entry.txHash, blockNumber, entry.contractAddress) + } + if err := r.store.SetLatestVersion(int64(blockNumber)); err != nil { //nolint:gosec fmt.Printf("failed to update latest version for block %d: %v\n", blockNumber, err) } } +// startReceiptReaders launches dedicated goroutines for receipt-by-hash lookups. +func (r *RecieptStoreSimulator) startReceiptReaders() { + readerCount := r.config.ReceiptReadConcurrency + totalReadsPerSec := r.config.ReceiptReadsPerSecond + if totalReadsPerSec <= 0 { + totalReadsPerSec = 1000 + } + + readsPerReader := totalReadsPerSec / readerCount + if readsPerReader < 1 { + readsPerReader = 1 + } + + for i := 0; i < readerCount; i++ { + //nolint:gosec // deterministic per-reader seed for benchmarks + readerRng := rand.New(rand.NewSource(r.config.Seed + int64(i) + 100)) + go r.tickerLoop(readsPerReader, readerRng, r.executeReceiptRead) + } + + fmt.Printf("Started %d receipt reader goroutines (%d reads/sec each)\n", + readerCount, readsPerReader) +} + +// startLogFilterReaders launches dedicated goroutines for log filter (eth_getLogs) queries. +func (r *RecieptStoreSimulator) startLogFilterReaders() { + readerCount := r.config.LogFilterReadConcurrency + totalReadsPerSec := r.config.LogFilterReadsPerSecond + if totalReadsPerSec <= 0 { + totalReadsPerSec = 100 + } + + readsPerReader := totalReadsPerSec / readerCount + if readsPerReader < 1 { + readsPerReader = 1 + } + + for i := 0; i < readerCount; i++ { + //nolint:gosec // deterministic per-reader seed, offset from receipt readers + readerRng := rand.New(rand.NewSource(r.config.Seed + int64(i) + 200)) + go r.tickerLoop(readsPerReader, readerRng, r.executeLogFilterRead) + } + + fmt.Printf("Started %d log filter reader goroutines (%d reads/sec each)\n", + readerCount, readsPerReader) +} + +func (r *RecieptStoreSimulator) tickerLoop(readsPerSecond int, rng *rand.Rand, fn func(*rand.Rand)) { + interval := time.Second / time.Duration(readsPerSecond) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-r.ctx.Done(): + return + case <-ticker.C: + fn(rng) + } + } +} + +// executeReceiptRead reconstructs a tx hash from a sampled (block, txIndex) pair and queries it. +// +// ReceiptColdReadRatio explicitly splits reads between: +// - hot reads in the stable near-tip cache chunk +// - cold reads older than the estimated cache window +// +// Within the chosen range, ReceiptReadRecencyExponent still applies a power-law +// recency bias so reads cluster toward the newest blocks in that range. +func (r *RecieptStoreSimulator) executeReceiptRead(rng *rand.Rand) { + latestBlock := r.store.LatestVersion() + if latestBlock <= 0 { + return + } + + earliestBlock := int64(1) + if r.config.ReceiptKeepRecent > 0 && latestBlock > r.config.ReceiptKeepRecent { + earliestBlock = latestBlock - r.config.ReceiptKeepRecent + 1 + } + + randomBlock := selectReceiptReadBlock( + rng, + earliestBlock, + latestBlock, + r.receiptStableHotWindowBlocks, + r.receiptCacheWindowBlocks, + r.config.ReceiptColdReadRatio, + r.config.ReceiptReadRecencyExponent, + ) + if randomBlock <= 0 { + return + } + randomTxIdx := rng.Intn(r.config.TransactionsPerBlock) + + hashBytes := SyntheticTxHash(r.crand, uint64(randomBlock), uint32(randomTxIdx)) //nolint:gosec + txHash := common.BytesToHash(hashBytes) + + r.metrics.ReportReceiptRead() + + sdkCtx := sdk.NewContext(nil, tmproto.Header{}, false) + start := time.Now() + rcpt, err := r.store.GetReceipt(sdkCtx, txHash) + r.metrics.RecordReceiptReadDuration(time.Since(start).Seconds()) + + if err != nil { + r.metrics.ReportReceiptError() + return + } + if rcpt != nil { + r.metrics.ReportReceiptReadFound() + return + } + r.metrics.ReportReceiptReadNotFound() +} + +func selectReceiptReadBlock( + rng *rand.Rand, + earliestBlock, latestBlock int64, + hotWindowBlocks, cacheWindowBlocks uint64, + coldReadRatio, exponent float64, +) int64 { + if latestBlock < earliestBlock { + return 0 + } + if hotWindowBlocks == 0 || cacheWindowBlocks == 0 { + return sampleRecencyBiasedBlock(rng, earliestBlock, latestBlock, exponent) + } + + coldLatest := latestBlock - int64(cacheWindowBlocks) //nolint:gosec // G115: window sizes are small config values, overflow is impossible + if coldReadRatio > 0 && coldLatest >= earliestBlock && rng.Float64() < coldReadRatio { + return sampleRecencyBiasedBlock(rng, earliestBlock, coldLatest, exponent) + } + + hotEarliest := latestBlock - int64(hotWindowBlocks) + 1 //nolint:gosec // G115: window sizes are small config values, overflow is impossible + if hotEarliest < earliestBlock { + hotEarliest = earliestBlock + } + return sampleRecencyBiasedBlock(rng, hotEarliest, latestBlock, exponent) +} + +func sampleRecencyBiasedBlock(rng *rand.Rand, earliestBlock, latestBlock int64, exponent float64) int64 { + if latestBlock < earliestBlock { + return 0 + } + blockRange := latestBlock - earliestBlock + 1 + + // Power-law recency bias: u^exponent maps uniform [0,1) toward newer blocks. + u := rng.Float64() + offset := int64(math.Pow(u, exponent) * float64(blockRange)) + if offset >= blockRange { + offset = blockRange - 1 + } + return latestBlock - offset +} + +// executeLogFilterRead simulates an eth_getLogs query filtering by contract address +// over a small block range, which is the typical RPC pattern. Contract addresses +// come from the ring buffer since they aren't deterministically derivable from a tx ID. +// +// LogFilterColdReadRatio controls the hot/cold split: +// - Hot reads (1 - ratio): pick a random fromBlock explicitly within the +// in-memory cache window so the query is guaranteed to hit the ledger cache. +// - Cold reads (ratio): pick a random fromBlock older than the cache window, +// forcing a DuckDB query on closed parquet files. +// +// Default ratio 0.1 yields ~90% cache hits. +func (r *RecieptStoreSimulator) executeLogFilterRead(rng *rand.Rand) { + entry := r.txRing.RandomEntry(rng) + if entry == nil { + return + } + + latestVersion := r.store.LatestVersion() + if latestVersion <= 0 { + return + } + + rangeSize := uint64(10) + (rng.Uint64() % 91) + var fromBlock, toBlock uint64 + + coldRatio := r.config.LogFilterColdReadRatio + latest := uint64(latestVersion) //nolint:gosec + cacheWindow := r.receiptCacheWindowBlocks + + if coldRatio > 0 && cacheWindow > 0 && latest > cacheWindow && rng.Float64() < coldRatio { + // Cold: random block range entirely before the cache window. + coldLatest := latest - cacheWindow + earliestBlock := uint64(1) + if r.config.ReceiptKeepRecent > 0 && latest > uint64(r.config.ReceiptKeepRecent) { //nolint:gosec + earliestBlock = latest - uint64(r.config.ReceiptKeepRecent) + 1 //nolint:gosec + } + if coldLatest > earliestBlock { + fromBlock = earliestBlock + (rng.Uint64() % (coldLatest - earliestBlock)) + toBlock = fromBlock + rangeSize + if toBlock > coldLatest { + toBlock = coldLatest + } + } else { + fromBlock = entry.blockNumber + toBlock = fromBlock + rangeSize + } + } else { + // Hot: random block range within the cache window. + hotEarliest := latest - cacheWindow + 1 + if hotEarliest < 1 { + hotEarliest = 1 + } + hotRange := latest - hotEarliest + 1 + fromBlock = hotEarliest + (rng.Uint64() % hotRange) + toBlock = fromBlock + rangeSize + } + + if toBlock > latest { + toBlock = latest + } + + crit := filters.FilterCriteria{ + Addresses: []common.Address{entry.contractAddress}, + } + + sdkCtx := sdk.NewContext(nil, tmproto.Header{}, false) + start := time.Now() + logs, err := r.store.FilterLogs(sdkCtx, fromBlock, toBlock, crit) + r.metrics.RecordReceiptLogFilterDuration(time.Since(start).Seconds()) + r.metrics.RecordLogFilterLogsReturned(int64(len(logs))) + + if err != nil { + r.metrics.ReportReceiptError() + } +} + // convertLogsForTx converts evmtypes.Log entries to ethtypes.Log entries. // Mirrors receipt.getLogsForTx. func convertLogsForTx(rcpt *evmtypes.Receipt, logStartIndex uint) []*ethtypes.Log { diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go new file mode 100644 index 0000000000..8845fcacca --- /dev/null +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go @@ -0,0 +1,71 @@ +package cryptosim + +import ( + "math/rand" + "testing" +) + +func TestSelectReceiptReadBlockRanges(t *testing.T) { + tests := []struct { + name string + earliestBlock int64 + latestBlock int64 + hotWindowBlocks uint64 + cacheWindowBlocks uint64 + coldReadRatio float64 + minBlock int64 + maxBlock int64 + }{ + { + name: "hot reads stay near tip", + earliestBlock: 1, + latestBlock: 10_000, + hotWindowBlocks: 500, + cacheWindowBlocks: 1_000, + coldReadRatio: 0, + minBlock: 9_501, + maxBlock: 10_000, + }, + { + name: "cold reads stay outside cache", + earliestBlock: 1, + latestBlock: 10_000, + hotWindowBlocks: 500, + cacheWindowBlocks: 1_000, + coldReadRatio: 1, + minBlock: 1, + maxBlock: 9_000, + }, + { + name: "short chains fall back to hot range", + earliestBlock: 1, + latestBlock: 700, + hotWindowBlocks: 500, + cacheWindowBlocks: 1_000, + coldReadRatio: 1, + minBlock: 201, + maxBlock: 700, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test RNG + + for i := 0; i < 1_000; i++ { + block := selectReceiptReadBlock( + rng, + tt.earliestBlock, + tt.latestBlock, + tt.hotWindowBlocks, + tt.cacheWindowBlocks, + tt.coldReadRatio, + 3, + ) + if block < tt.minBlock || block > tt.maxBlock { + t.Fatalf("expected block in [%d,%d], got %d", tt.minBlock, tt.maxBlock, block) + } + } + }) + } +}