Skip to content

Commit 8484281

Browse files
committed
Add namespace-filtered delivery and block DB retention for multi-replica demo
Each EVM gateway replica now skips transactions from foreign Fabric namespaces (prevents O(N) delivery cost in a 5-replica setup). Adds TruncateBlocks to prune SQLite to a configurable retention window, triggered every 1000 blocks. Also adds USDC demo deployment runbook in integration/perf/USDC_deployment.md. Signed-off-by: Eyal Kushnir <eyalk7@gmail.com> Signed-off-by: Eyal Kushnir <Eyal.Kushnir@ibm.com>
1 parent b997aed commit 8484281

11 files changed

Lines changed: 911 additions & 16 deletions

File tree

Makefile

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ build-release:
2424
-o release/linux-$$arch/fxevm ./cmd/fxevm || exit 1; \
2525
done
2626

27+
IMAGE_TAG ?= dev
28+
2729
.PHONY: build-image
2830
build-image: build-release
2931
$(DOCKER) buildx build \
@@ -32,7 +34,7 @@ build-image: build-release
3234
--build-arg VERSION=dev \
3335
--build-arg CREATED=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) \
3436
--build-arg REVISION=$(shell git rev-parse HEAD) \
35-
--tag fabric-x-evm:dev \
37+
--tag fabric-x-evm:$(IMAGE_TAG) \
3638
.
3739

3840
.PHONY: checks
@@ -193,3 +195,16 @@ hardhat-tests:
193195
.PHONY: perf-tests
194196
perf-tests: pre-pull-images
195197
@VERBOSE=$(VERBOSE) FABRIC_VERSION=$(FABRIC_VERSION) ./scripts/run_perf_test.sh
198+
199+
# STAGING_HOST, SSH_USER, EVM_BRANCH, and DEMO_ARGS may be overridden on the command line.
200+
STAGING_HOST ?= dectrust8.vpc.cloud9.ibm.com
201+
SSH_USER ?= root
202+
EVM_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
203+
204+
.PHONY: run-demo
205+
run-demo:
206+
scripts/run-demo.sh \
207+
--staging-host $(STAGING_HOST) \
208+
--ssh-user $(SSH_USER) \
209+
--evm-branch $(EVM_BRANCH) \
210+
$(DEMO_ARGS)

gateway/app/app.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func buildApp(cfg config.Config, gwSigner sdk.Signer, logger sdk.Logger, endorse
114114
return nil, fmt.Errorf("failed to create submitter: %w", err)
115115
}
116116

117-
chain, err := core.NewChain(cfg.Gateway.Database.ConnString, cfg.Gateway.Database.TriePath, false)
117+
chain, err := core.NewChain(cfg.Gateway.Database.ConnString, cfg.Gateway.Database.TriePath, cfg.Network.Namespace, false)
118118
if err != nil {
119119
return nil, fmt.Errorf("failed to create chain: %w", err)
120120
}
@@ -196,8 +196,12 @@ func (a *App) Run(ctx context.Context) error {
196196
g.Go(func() error { return a.gwSync.Start(gctx) })
197197

198198
// Wait for initial sync before serving traffic
199+
syncTimeout := a.cfg.Gateway.SyncTimeout
200+
if syncTimeout == 0 {
201+
syncTimeout = 5 * time.Minute
202+
}
199203
for i, sync := range a.endorserSyncs {
200-
if err := waitUntilSynced(gctx, sync, 10*time.Second); err != nil {
204+
if err := waitUntilSynced(gctx, sync, syncTimeout); err != nil {
201205
return err
202206
}
203207
appLogger.Debugf("endorser %d synced", i)

gateway/core/api.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ func (g *Gateway) Start(ctx context.Context) {
100100
// worker processes transactions from the queue
101101
func (g *Gateway) worker(ctx context.Context) {
102102
defer g.wg.Done()
103+
defer func() {
104+
if r := recover(); r != nil {
105+
logger.Errorf("worker panic: %v", r)
106+
if ctx.Err() == nil {
107+
g.wg.Add(1)
108+
go g.worker(ctx)
109+
}
110+
}
111+
}()
103112

104113
for {
105114
tx, ok := g.TxQueue.Dequeue()

gateway/core/chain.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,30 @@ import (
2828
// (for block ingestion) and core.Store (via the embedded *storage.Store, for API queries).
2929
type Chain struct {
3030
*storage.Store
31-
db *sql.DB
32-
ts *trie.Store
33-
prevHash common.Hash // Ethereum hash of last committed block; seeded from DB on startup
31+
db *sql.DB
32+
ts *trie.Store
33+
prevHash common.Hash // Ethereum hash of last committed block; seeded from DB on startup
34+
namespace string // Fabric namespace to filter; empty means accept all
3435
}
3536

3637
// NewChain opens the SQLite database and trie store, seeds state from the latest committed
3738
// block, and returns a ready Chain. dbConnStr uses the modernc SQLite DSN format;
3839
// triePath is the directory for the PebbleDB trie (empty string = in-memory).
40+
// namespace filters delivered blocks to only those containing transactions for that Fabric
41+
// namespace; pass an empty string to accept all namespaces.
3942
// The caller must register the SQLite driver (e.g. _ "modernc.org/sqlite") before calling.
40-
func NewChain(dbConnStr, triePath string, withTrie bool) (*Chain, error) {
43+
func NewChain(dbConnStr, triePath, namespace string, withTrie bool) (*Chain, error) {
4144
db, err := sqlite.Open(dbConnStr)
4245
if err != nil {
4346
return nil, fmt.Errorf("open db: %w", err)
4447
}
48+
// Store SQLite temp objects in memory. The release image is built from
49+
// scratch and has no /tmp, so file-based temp storage would fail with
50+
// SQLITE_IOERR_GETTEMPPATH (6410) during WAL operations like DELETE.
51+
if _, err := db.Exec("PRAGMA temp_store=MEMORY"); err != nil {
52+
db.Close()
53+
return nil, fmt.Errorf("set temp_store pragma: %w", err)
54+
}
4555

4656
blockStore := storage.NewStore(db)
4757
if err := blockStore.Init(); err != nil {
@@ -66,13 +76,31 @@ func NewChain(dbConnStr, triePath string, withTrie bool) (*Chain, error) {
6676
}
6777
}
6878

69-
return &Chain{Store: blockStore, db: db, ts: ts, prevHash: prevHash}, nil
79+
return &Chain{Store: blockStore, db: db, ts: ts, prevHash: prevHash, namespace: namespace}, nil
80+
}
81+
82+
// convertToDomain applies namespace filtering then delegates to ConvertToDomain.
83+
func (c *Chain) convertToDomain(b blocks.Block) domain.Block {
84+
if c.namespace == "" {
85+
return ConvertToDomain(b)
86+
}
87+
filtered := b
88+
filtered.Transactions = filtered.Transactions[:0:0]
89+
for _, tx := range b.Transactions {
90+
for _, nrws := range tx.NsRWS {
91+
if nrws.Namespace == c.namespace {
92+
filtered.Transactions = append(filtered.Transactions, tx)
93+
break
94+
}
95+
}
96+
}
97+
return ConvertToDomain(filtered)
7098
}
7199

72100
// Handle implements blocks.BlockHandler. It commits the block's write sets to the trie,
73101
// then persists the block and its transactions to the database.
74102
func (c *Chain) Handle(ctx context.Context, b blocks.Block) error {
75-
ebl := ConvertToDomain(b)
103+
ebl := c.convertToDomain(b)
76104

77105
ebl.ParentHash = c.prevHash.Bytes()
78106
if c.ts != nil {
@@ -90,6 +118,12 @@ func (c *Chain) Handle(ctx context.Context, b blocks.Block) error {
90118
return err
91119
}
92120

121+
if c.Store.BlockRetention > 0 && ebl.BlockNumber%1000 == 0 {
122+
if err := c.Store.TruncateBlocks(ctx, c.Store.BlockRetention); err != nil {
123+
logger.Warnf("block DB truncation failed at block %d: %v", ebl.BlockNumber, err)
124+
}
125+
}
126+
93127
return nil
94128
}
95129

@@ -115,8 +149,6 @@ func ConvertToDomain(b blocks.Block) domain.Block {
115149

116150
logIndex := int64(0) // logIndex is the index of the log in the block
117151
for _, tx := range b.Transactions {
118-
// TODO: filter on namespace?
119-
120152
// retrieve the Ethereum transaction from the chaincode invocation
121153
if len(tx.InputArgs) < 2 || !bytes.Equal(tx.InputArgs[0], []byte{byte(fc.ProposalTypeEVMTx)}) {
122154
// skip non-eth tx
@@ -129,7 +161,8 @@ func ConvertToDomain(b blocks.Block) domain.Block {
129161

130162
etx, err := convertTransaction(tx.InputArgs[1], b.Hash, b.Number, tx.Number, tx.ID, status, tx.Status, tx.Events, &logIndex)
131163
if err != nil {
132-
panic(err) // we surface this for now instead of swallowing it
164+
logger.Warnf("skipping malformed tx %s in block %d: %v", tx.ID, b.Number, err)
165+
continue
133166
}
134167

135168
ebl.Transactions = append(ebl.Transactions, etx)

gateway/core/chain_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,49 @@ func TestConvertToDomain_EmptyBlock(t *testing.T) {
129129
assert.Len(t, got.Transactions, 0)
130130
}
131131

132+
func TestConvertToDomain_NamespaceFilter(t *testing.T) {
133+
key, err := crypto.GenerateKey()
134+
require.NoError(t, err)
135+
ethb := marshaledEthTx(t, key, common.HexToAddress("0x1111111111111111111111111111111111111111"), big.NewInt(1))
136+
137+
matchTx := blocks.Transaction{
138+
ID: "tx-match",
139+
Valid: true,
140+
InputArgs: [][]byte{{byte(co.ProposalTypeEVMTx)}, ethb},
141+
NsRWS: []blocks.NsReadWriteSet{{Namespace: "my-ns"}},
142+
}
143+
foreignTx := blocks.Transaction{
144+
ID: "tx-foreign",
145+
Valid: true,
146+
InputArgs: [][]byte{{byte(co.ProposalTypeEVMTx)}, ethb},
147+
NsRWS: []blocks.NsReadWriteSet{{Namespace: "other-ns"}},
148+
}
149+
150+
t.Run("filters out foreign namespace", func(t *testing.T) {
151+
c := &Chain{namespace: "my-ns"}
152+
got := c.convertToDomain(blocks.Block{Number: 1, Transactions: []blocks.Transaction{matchTx, foreignTx}})
153+
require.Len(t, got.Transactions, 1)
154+
assert.Equal(t, "tx-match", got.Transactions[0].FabricTxID)
155+
})
156+
157+
t.Run("no-op when namespace is empty", func(t *testing.T) {
158+
c := &Chain{}
159+
got := c.convertToDomain(blocks.Block{Number: 1, Transactions: []blocks.Transaction{matchTx, foreignTx}})
160+
assert.Len(t, got.Transactions, 2)
161+
})
162+
163+
t.Run("skips tx with no NsRWS when namespace set", func(t *testing.T) {
164+
noNsTx := blocks.Transaction{
165+
ID: "tx-no-ns",
166+
Valid: true,
167+
InputArgs: [][]byte{{byte(co.ProposalTypeEVMTx)}, ethb},
168+
}
169+
c := &Chain{namespace: "my-ns"}
170+
got := c.convertToDomain(blocks.Block{Number: 1, Transactions: []blocks.Transaction{noNsTx}})
171+
assert.Len(t, got.Transactions, 0)
172+
})
173+
}
174+
132175
// --- convertTransaction ---
133176

134177
func TestConvertTransaction_RegularTransfer(t *testing.T) {

gateway/storage/store.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ type Store struct {
2424
queries *Queries
2525
DB *sql.DB
2626
CachedBlockNumber atomic.Uint64 // cached block number for fast reads
27+
BlockRetention int // number of recent blocks to keep; 0 disables truncation
2728
}
2829

30+
const DefaultBlockRetention = 10000
31+
2932
func NewStore(db *sql.DB) *Store {
3033
return &Store{
31-
queries: New(db),
32-
DB: db,
34+
queries: New(db),
35+
DB: db,
36+
BlockRetention: DefaultBlockRetention,
3337
}
3438
}
3539

@@ -451,6 +455,34 @@ func (s *Store) GetLogs(ctx context.Context, filter domain.LogFilter) ([]domain.
451455
return logs, nil
452456
}
453457

458+
// TruncateBlocks deletes blocks older than the last keepLastN blocks, along with
459+
// their associated transactions and logs. All three tables are deleted in a single
460+
// transaction to maintain referential integrity (logs and transactions have foreign
461+
// keys on block_number with no CASCADE, so they must be deleted first).
462+
// Returns nil immediately if there is nothing to truncate.
463+
func (s *Store) TruncateBlocks(ctx context.Context, keepLastN int) error {
464+
cutoff := int64(s.CachedBlockNumber.Load()) - int64(keepLastN)
465+
if cutoff <= 0 {
466+
return nil
467+
}
468+
sqlTx, err := s.DB.BeginTx(ctx, nil)
469+
if err != nil {
470+
return err
471+
}
472+
defer sqlTx.Rollback() //nolint:errcheck
473+
474+
for _, stmt := range []string{
475+
`DELETE FROM logs WHERE block_number <= ?`,
476+
`DELETE FROM transactions WHERE block_number <= ?`,
477+
`DELETE FROM blocks WHERE block_number <= ?`,
478+
} {
479+
if _, err := sqlTx.Exec(stmt, cutoff); err != nil {
480+
return err
481+
}
482+
}
483+
return sqlTx.Commit()
484+
}
485+
454486
// GetLogsByTxHash retrieves all logs for a specific transaction.
455487
func (s *Store) GetLogsByTxHash(ctx context.Context, txHash []byte) ([]domain.Log, error) {
456488
rows, err := s.queries.GetLogsByTxHash(ctx, txHash)

gateway/storage/store_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,79 @@ func TestInsertBlock_WithTransactionsAndLogs(t *testing.T) {
764764
}
765765
}
766766

767+
// TruncateBlocks tests
768+
769+
func TestTruncateBlocks(t *testing.T) {
770+
store := setupTestDB(t)
771+
772+
// Insert blocks 1-10, each with a transaction and a log
773+
for i := uint64(1); i <= 10; i++ {
774+
bh := makeHash(byte(i))
775+
insertTestBlock(t, store, i, bh)
776+
th := makeHash(byte(i + 100))
777+
insertTestTransaction(t, store, i, bh, th, 0)
778+
insertTestLog(t, store, i, th, makeAddress(0x01), 0, nil)
779+
}
780+
781+
// Keep last 5: blocks 6-10 should survive, 1-5 should be deleted
782+
err := store.TruncateBlocks(t.Context(), 5)
783+
if err != nil {
784+
t.Fatalf("TruncateBlocks error: %v", err)
785+
}
786+
787+
// Block 5 should be gone
788+
b, err := store.GetBlockByNumber(t.Context(), 5, false)
789+
if err != nil {
790+
t.Fatalf("GetBlockByNumber error: %v", err)
791+
}
792+
if b != nil {
793+
t.Error("expected block 5 to be deleted")
794+
}
795+
796+
// Block 6 should still exist
797+
b, err = store.GetBlockByNumber(t.Context(), 6, false)
798+
if err != nil {
799+
t.Fatalf("GetBlockByNumber error: %v", err)
800+
}
801+
if b == nil {
802+
t.Error("expected block 6 to survive")
803+
}
804+
805+
// Transactions for block 5 should be gone
806+
tx, err := store.GetTransactionByBlockNumberAndIndex(t.Context(), 5, 0)
807+
if err != nil {
808+
t.Fatalf("GetTransactionByBlockNumberAndIndex error: %v", err)
809+
}
810+
if tx != nil {
811+
t.Error("expected transaction for block 5 to be deleted")
812+
}
813+
814+
// Logs for blocks 1-5 should be gone
815+
five := uint64(5)
816+
logs, err := store.GetLogs(t.Context(), domain.LogFilter{ToBlock: &five})
817+
if err != nil {
818+
t.Fatalf("GetLogs error: %v", err)
819+
}
820+
if len(logs) != 0 {
821+
t.Errorf("expected 0 logs for blocks 1-5, got %d", len(logs))
822+
}
823+
}
824+
825+
func TestTruncateBlocks_NothingToDelete(t *testing.T) {
826+
store := setupTestDB(t)
827+
for i := uint64(1); i <= 3; i++ {
828+
insertTestBlock(t, store, i, makeHash(byte(i)))
829+
}
830+
// keepLastN > total blocks: nothing should be deleted
831+
if err := store.TruncateBlocks(t.Context(), 100); err != nil {
832+
t.Fatalf("TruncateBlocks error: %v", err)
833+
}
834+
b, _ := store.GetBlockByNumber(t.Context(), 1, false)
835+
if b == nil {
836+
t.Error("block 1 should not have been deleted")
837+
}
838+
}
839+
767840
// GetLogsByTxHash test
768841

769842
func TestGetLogsByTxHash(t *testing.T) {

0 commit comments

Comments
 (0)