A high-performance, horizontally-scalable Soroban event indexing service for the Checkmate Escrow contract. Supports multi-instance clusters with PostgreSQL backend, distributed leader election, read-replica support, and deterministic LRU caching.
- Rust 1.75+
- PostgreSQL 12+ (required; no longer using SQLite)
- Soroban testnet access
- Docker & Docker Compose (optional, for multi-instance local testing)
cd services/event-indexer
# 1. Start a local PostgreSQL instance
docker run --name event-pg -e POSTGRES_USER=event_indexer \
-e POSTGRES_PASSWORD=pass -e POSTGRES_DB=event_indexer \
-p 5432:5432 -d postgres:15-alpine
# 2. Configure environment
export DATABASE_URL=postgres://event_indexer:pass@localhost:5432/event_indexer
export CONTRACT_ESCROW=<your-56-char-stellar-contract-address>
export STELLAR_RPC_URL=https://soroban-testnet.stellar.org
# 3. Build and run
cargo build --release
cargo run --releaseThe service starts on http://localhost:8080. Check health:
curl http://localhost:8080/health
# {"db":"ok"}# From the repository root:
docker-compose up
# This starts:
# - 1 PostgreSQL instance
# - 2 event-indexer replicas (ports 8080, 8081)
# - Both replicas compete for leader lease; one becomes leader and ingests events
# Scale to 4 replicas:
docker-compose up --scale event-indexer-1=2 --scale event-indexer-2=2
# Check which instance is the leader:
docker-compose logs event-indexer-1 event-indexer-2 | grep "Became leader"- PostgreSQL backend — connection-pooled, ACID, ready for replication
- Leader election — distributed mutex via PG advisory locks + row-level lease; only one instance ingests at a time
- Idempotent ingestion —
INSERT … ON CONFLICT DO NOTHINGsemantics prevent duplicate events on replay or failover - Horizontal read scaling — separate read-replica pool for API queries; add instances to scale read throughput
- Deterministic LRU cache — strict insertion-order eviction using
indexmap; no undefined HashMap iteration
/health— DB connectivity check/events?player_address=X&status=completed&limit=100— query events with filters/events/:match_id— all events for a match (cache-first)/match/:match_id— full match summary/matches?status=active— list matches by status/stats— total events + cache size (requiresX-Api-Keyheader)/analytics/*,/transactions/player/:address— analytics & transaction history (requireX-Api-Keyheader)
See EVENT_INDEXER_API.md for complete documentation.
┌────────────────────────────────────────────────┐
│ Leader-elected Instance (writes events) │
│ ┌──────────────────────────────────────────┐ │
│ │ Soroban RPC poller (every 5s) │ │
│ └───────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────▼─────────┐ ┌──────────────┐ │
│ │ LRU Cache (10k) │ ◄── │ DB Write Pool│ │
│ └────────────────────┘ └──────┬───────┘ │
└──────────────────────────────────────┼─────────┘
│
┌──────────▼─────────┐
│ PostgreSQL Primary│
└──────────┬─────────┘
│ streaming replication
│
┌──────────▼─────────┐
│ PG Read Replica │
│ (optional) │
└────────────────────┘
▲
┌──────────────────────────────────────┼─────────┐
│ All instances (serve read traffic) │ │
│ ┌──────────────────────────────────┐│ │
│ │ REST API (Axum) ││ │
│ └───────────┬──────────────────────┘│ │
│ │ │ │
│ ┌──────────▼─────────┐ ┌───────┴──────┐ │
│ │ LRU Cache (10k) │ ◄── │DB Read Pool │ │
│ └────────────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────┘
- Uses PostgreSQL session-level advisory lock (
pg_try_advisory_lock) + row-level lease inleader_statetable. - Lease TTL = 30s; heartbeat every 10s.
- If leader dies, advisory lock auto-released; lease expires → follower takes over within ≤ 30s.
- No split-brain possible: only one session can hold the advisory lock at a time.
All ingestion uses:
INSERT INTO events (id, …) VALUES ($1, …)
ON CONFLICT (id) DO NOTHINGEvent id is a UUID derived from ledger + event data. Re-ingesting the same ledger range (e.g., after a crash) never creates duplicates.
- Each instance has an independent LRU cache (no distributed cache).
indexmap::IndexMapmaintains strict LRU order: front = least-recently-used (evicted first); back = most-recently-used.- Cache misses go to the DB read pool.
Environment variables:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
required | Primary PostgreSQL DSN e.g. postgres://user:pass@host:5432/dbname |
DATABASE_READ_URL |
$DATABASE_URL |
Read-replica DSN (optional; falls back to primary) |
STELLAR_RPC_URL |
testnet | Soroban JSON-RPC endpoint |
CONTRACT_ESCROW |
required | Escrow contract address (56 chars) |
EVENT_INDEXER_INSTANCE_ID |
hostname / UUID | Unique ID for this instance |
EVENT_INDEXER_BIND_ADDR |
127.0.0.1 |
API listen address |
EVENT_INDEXER_PORT |
8080 |
API port |
EVENT_INDEXER_CACHE_SIZE |
10000 |
LRU cache capacity |
EVENT_INDEXER_API_KEY |
(fail closed) | Shared secret for the admin-like endpoints (/stats, /analytics/*, /transactions/*). Without it they refuse every request with 401 |
EVENT_INDEXER_POLL_INTERVAL |
5 |
Polling interval (1–60 seconds) |
EVENT_INDEXER_LOG_LEVEL |
info |
Log level (error, warn, info, debug, trace) |
EVENT_INDEXER_LEADER_TTL_SECS |
30 |
Leader lease validity (seconds) |
EVENT_INDEXER_LEADER_HEARTBEAT_SECS |
10 |
Lease renewal interval (must be < TTL) |
EVENT_INDEXER_DB_POOL_SIZE |
5 |
Write pool connections |
EVENT_INDEXER_DB_READ_POOL_SIZE |
10 |
Read pool connections |
# Unit tests (cache, config, no DB required)
cargo test
# Integration tests (requires DATABASE_URL)
DATABASE_URL=postgres://user:pass@localhost:5432/test_db cargo test
# Load tests (throughput measurement, no DB required)
cargo test --release -- --nocapture load_test- ✅ LRU eviction determinism
- ✅ Multi-instance no-duplicate ingestion simulation
- ✅ Leader-only ingestion correctness
- ✅ Leader failover with overlapping replay
- ✅ Concurrent cache insert/read throughput (≥ 5 000 ops/s target)
- ✅ N-instance read-scaling benchmark
- Query latency: < 50ms (< 10ms from cache)
- Ingestion throughput: 100+ events/second (RPC-bound)
- Cache hit rate: ~80% for typical workloads
- Throughput: 1000+ queries/second per instance
- Read scaling: ~3× query throughput (queries spread across all instances)
- Failover: ≤ 30s worst-case to elect new leader
- Zero duplicate events during failover (idempotent
ON CONFLICT DO NOTHING)
psql $DATABASE_URL -c "SELECT instance_id, held_until FROM leader_state"psql $DATABASE_URL -c "DELETE FROM leader_state"
# Next poll cycle → a new leader is electeddocker-compose up --scale event-indexer-1=2
# New instance auto-starts, competes for leader lease, serves read traffic immediatelydocker-compose stop event-indexer-2
# If it was leader, lease expires within 30s → another instance takes over- Set up PostgreSQL streaming replication (standard PG procedure).
- Set
DATABASE_READ_URL=postgres://…replica…on all indexer instances. - Restart indexer instances. All query traffic now routes to the replica.
# Logs from all replicas
docker-compose logs -f event-indexer-1 event-indexer-2
# Structured JSON logs
EVENT_INDEXER_LOG_LEVEL=debug cargo run 2>&1 | jq- Scaling Architecture — complete architectural guide, failure modes, runbook
- API Reference — endpoint specs, request/response schemas
- Deployment Guide — production deployment checklist
If you have an existing SQLite-based deployment:
- Export events from SQLite → CSV.
- Provision PostgreSQL and run schema init.
- Import CSV into PG
eventstable. - Update environment variables (
DATABASE_URL, removeEVENT_INDEXER_DB_PATH). - Restart the service.
The new version is backward-compatible at the API level; no client changes are needed.
- Create feature branch
- Add tests for new functionality
- Ensure
cargo testandcargo fmtpass - Submit PR with description
See LICENSE file in repository root.