Blockchain event ingestion & query layer for the Tikka raffle platform.
Subscribes to Stellar ledger events, decodes Tikka contract events, and writes structured data to PostgreSQL.
| Requirement | Version |
|---|---|
| Node.js | ≥ 20 |
| PostgreSQL | ≥ 15 |
| (Optional) Docker | for local Postgres |
Create a .env.local file in this directory (the file is gitignored):
# Option A — single connection string (preferred)
DATABASE_URL=postgres://postgres:postgres@localhost:5432/tikka_indexer
# Option B — individual vars (used if DATABASE_URL is not set)
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=tikka_indexer
# Set to "true" on Supabase / Railway (requires SSL)
DB_SSL=false
# Slow query logging threshold in milliseconds (default: 200)
SLOW_QUERY_THRESHOLD_MS=200
# Application port (default: 3002)
PORT=3002
# Health endpoint: Horizon URL for latest-ledger check (default: https://horizon.stellar.org)
HORIZON_URL=https://horizon.stellar.org
# Health: lag above this many ledgers is reported as degraded (default: 100)
LAG_THRESHOLD=100
# Health: lag above this many ledgers triggers critical alerts and notifications (default: 50)
INDEXER_LAG_ALERT_THRESHOLD_LEDGERS=50The indexer exports two database metrics for query performance:
tikka_db_query_duration_seconds— histogram of all query durationstikka_db_slow_query_total— counter of slow queries by query hash
Slow queries are logged at WARN level with a stable SHA-256 hash of the normalized query template. Raw SQL text is not emitted by the logger.
To correlate the query hash with PostgreSQL SQL text, enable pg_stat_statements and compute the same normalized SHA-256 hash:
SELECT
encode(digest(regexp_replace(query, '\s+', ' ', 'g'), 'sha256'), 'hex') AS query_hash,
query,
calls,
total_time,
mean_time
FROM pg_stat_statements
WHERE query ILIKE '%your_fragment%'
ORDER BY total_time DESC
LIMIT 20;Replace your_fragment with a portion of the suspected slow query. The query_hash label in Prometheus should match the computed hash above.
docker run -d \
--name tikka-pg \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=tikka_indexer \
-p 5432:5432 \
postgres:16-alpine# Install dependencies
npm install
# Development (with hot-reload)
npm run start:dev
# Production
npm run build
npm startMigrations run automatically when the app bootstraps — no manual step needed.
TypeORM'smigrationsRun: trueapplies all pending migrations before the server starts listening.
You can view the real-time status of the indexer, including lag, DLQ size, cache state, and last processed event using the CLI status command.
# View status as a human-readable table
pnpm run status
# View status as JSON (useful for scripts)
pnpm run status -- --jsonOutput example:
Tikka Indexer Status 2023-10-27T10:00:00.000Z
──────────────────────────────────────────────────
Ledger
Current (indexed) 1000
Horizon (latest) 1005
Lag 5 ledgers
Events
Total processed 500
Last 24 h 10
Last processed at 2023-10-27T09:55:00.000Z
DLQ
Total size 0
Cache
Status ok
Latency 2 ms
Database
Status ok
Pool (total / idle / wait) 10 / 10 / 0
──────────────────────────────────────────────────
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/tikka_indexer
npm run migration:runnpm run migration:revertnpm run migration:generate -- src/database/migrations/YourMigrationName| Table | Description |
|---|---|
raffles |
One row per on-chain raffle |
tickets |
One row per purchased ticket |
users |
Aggregated per-address participation stats |
raffle_events |
Append-only log of decoded contract events |
platform_stats |
Daily aggregate roll-ups |
indexer_cursor |
Singleton row tracking the last processed ledger |
Full schema specification: docs/ARCHITECTURE.md § Data Model.
Each entity includes raw chain state (source-of-truth from Stellar ledger) and derived query state (computed aggregates). Contributors need to understand which fields are safe to recalculate and which are immutable.
📖 Entity Ownership Documentation
This document describes:
- Field ownership (raw chain state vs derived)
- Updater handlers for each entity
- Which fields are safe to recalculate
- Idempotency keys and replay protection
- Migration ownership rules
All entity files include inline comments marking derived fields and their updater handlers.
See docs/CACHE.md for every cache key family: TTL, event-driven invalidation triggers, and staleness tolerance.
Caching logic is wired into the processors in src/processors/ to ensure consistency after database writes.
- Config file:
indexer/redis.conf maxmemory 4gb,maxmemory-policy allkeys-lru,maxmemory-samples 5.- Eviction policy:
allkeys-lru, to keep frequently-accessed data and evict least recently used keys across all key namespaces. - TTLs are still used in CacheService for key lifecycle control.
CacheService periodically calls Redis INFO memory and logs:
- WARNING when usage >= 80%
- CRITICAL when usage >= 90%
Data monitored:
used_memorymaxmemorymemory usage percentage
CacheService tracks per bucket:
raffles,users,stats,othershits,misses,requestshit rate(percent) viagetAllCacheHitRates()
Use this data to detect hot sets and to tune TTLs per data type.
- Set
maxmemoryto ~50% of host RAM for dedicated cache nodes. - Use
allkeys-lrufor general purpose cache with mixed TTL keys. - Enable Redis slowlog & keyspace notifications for degraded performance debugging.
- Scale horizontally with Redis Cluster if items exceed node limit.
GET /health is intended for orchestration and monitoring. It reports indexer lag, DB connectivity, and Redis connectivity.
| Field | Type | Description |
|---|---|---|
status |
'ok' | 'degraded' |
ok when DB and Redis are up and lag is within threshold; degraded otherwise. |
lag_ledgers |
number | null |
Current ledger (from Horizon) minus last processed ledger (cursor). null if Horizon is unreachable or cursor not yet set. |
lagStatus |
'healthy' | 'degraded' | 'critical' |
Lag status based on alert thresholds. healthy ≤ alert threshold, degraded > LAG_THRESHOLD, critical > INDEXER_LAG_ALERT_THRESHOLD_LEDGERS. |
db |
'ok' | 'error' |
PostgreSQL connectivity. |
redis |
'ok' | 'error' |
Redis connectivity (ping). |
redis_latency_ms |
number | null |
Redis ping latency in milliseconds. |
dlq_size |
number |
Number of events in the dead-letter queue. |
- HTTP 200:
status === 'ok'. - HTTP 503:
status === 'degraded'(e.g. lag >LAG_THRESHOLD, or DB/Redis down).
Example (200):
{
"status": "ok",
"lag_ledgers": 12,
"lagStatus": "healthy",
"db": "ok",
"redis": "ok",
"redis_latency_ms": 5,
"dlq_size": 0
}Example (503, degraded by lag):
{
"status": "degraded",
"lag_ledgers": 150,
"lagStatus": "critical",
"db": "ok",
"redis": "ok",
"redis_latency_ms": 8,
"dlq_size": 0
}The indexer emits indexer_lag_alert events when the lag status crosses into critical territory. This allows external services (like PushNotificationService in the backend) to receive real-time notifications.
Event payload:
{
"lag_ledgers": 75,
"threshold": 50,
"timestamp": "2024-01-01T12:00:00.000Z"
}To listen for lag alerts:
// In your service constructor
constructor(private healthService: HealthService) {
healthService.getEventEmitter().on('indexer_lag_alert', (alert) => {
console.log('Critical lag detected:', alert);
// Send notification, trigger alert, etc.
});
}- Alert if
lag_ledgers> 100 (or your chosen threshold): indexer is falling behind; investigate ingestion pipeline or Horizon availability. - Alert if
lagStatus==='critical': indexer is severely lagging; investigate ingestion pipeline or Horizon availability. - Alert if
db==='error': database unreachable; check Postgres and network. - Alert if
redis==='error': cache unreachable; optional for correctness but affects performance. - Use HTTP 503 as a readiness probe failure in Kubernetes/orchestration so the instance is not sent traffic when degraded.
For Kubernetes deployments, you can configure liveness probes that fail when lagStatus === 'critical'. See kubernetes/liveness-probe-example.yaml for complete examples.
src/
├── app.module.ts # Root NestJS module
├── main.ts # Bootstrap entry point
├── data-source.ts # TypeORM CLI DataSource (migration scripts)
├── config/
│ └── database.config.ts # TypeORM config factory
├── cache/
│ ├── cache.module.ts
│ └── cache.service.ts # Redis TTL strategies per data type
├── health/
│ ├── health.controller.ts # GET /health
│ ├── health.module.ts
│ └── health.service.ts # DB, Redis, Horizon lag checks
├── ingestor/
│ ├── cursor-manager.service.ts
│ └── ingestor.module.ts
├── api/
│ ├── api.module.ts # Internal HTTP API
│ └── controllers/
│ ├── raffles.controller.ts
│ ├── users.controller.ts
│ ├── leaderboard.controller.ts
│ └── stats.controller.ts
├── processors/
│ ├── processors.module.ts
│ ├── raffle.processor.ts
│ └── user.processor.ts
├── maintenance/
│ ├── archive-raffle-events.ts # Resumable archiving utility
│ ├── archive-raffle-events.spec.ts # Archiving tests
│ ├── ARCHIVE_RAFFLE_EVENTS_GUIDE.md # Comprehensive guide
│ ├── ARCHIVE_QUICK_REF.md # Quick reference
│ └── ARCHIVE_IMPLEMENTATION_SUMMARY.md # Technical summary
└── database/
├── database.module.ts # TypeOrmModule wiring
├── entities/
│ ├── raffle.entity.ts
│ ├── ticket.entity.ts
│ ├── user.entity.ts
│ ├── raffle-event.entity.ts
│ ├── platform-stat.entity.ts
│ ├── indexer-cursor.entity.ts
│ └── archive-checkpoint.entity.ts # Archiving checkpoint tracking
└── migrations/
├── 1700000000000-CreateRaffles.ts
├── 1700000000001-CreateTickets.ts
├── 1700000000002-CreateUsers.ts
├── 1700000000003-CreateRaffleEvents.ts
├── 1700000000004-CreatePlatformStats.ts
├── 1700000000005-CreateIndexerCursor.ts
└── 1748589373000-CreateArchiveCheckpoints.ts
The indexer includes a robust archiving utility for managing raffle_events table growth. The archiver exports old events to CSV and safely removes them from the database with built-in resumption support.
# Test archiving (dry-run, no changes)
npm run archive:raffle-events
# Production archiving (interactive — type "yes" when prompted)
DRY_RUN=false npm run archive:raffle-events
# Production / cron (explicit confirmation required)
CONFIRM_DELETE=yes DRY_RUN=false npm run archive:raffle-events
# Archive events older than 90 days
RAFFLE_EVENTS_RETENTION_DAYS=90 CONFIRM_DELETE=yes DRY_RUN=false npm run archive:raffle-events✅ Resumable Checkpointing - Automatically resumes after interruptions
✅ Dry-Run Mode - Test without modifying database
✅ Delete Confirmation - TTY prompt or CONFIRM_DELETE=yes before deletes
✅ Batch Limits - Control processing with MAX_BATCH parameter
✅ Transactional Safety - Atomic checkpoint updates with deletions
✅ Structured Logging - JSON-formatted progress tracking
| Variable | Default | Description |
|---|---|---|
RAFFLE_EVENTS_RETENTION_DAYS |
30 |
Archive events older than N days |
BATCH_SIZE |
500 |
Records per batch |
MAX_BATCH |
unlimited | Maximum batches per run |
DRY_RUN |
true |
Simulate without changes |
CONFIRM_DELETE |
unset | Required yes for non-interactive deletes |
RESUME |
true |
Resume from checkpoint |
Archives are written to ./archives/ directory:
raffle_events_2026-05-30_batch0001.csv
raffle_events_2026-05-30_batch0002.csv
- 📜 Retention policy & restore - Criteria, cadence, destination, restore
- 📖 Comprehensive Guide - Full documentation
- 📋 Quick Reference - Common commands
- 🔧 Implementation Summary - Technical details
- CPU: 200m requests, 1000m limits
- Memory: 512Mi requests, 1Gi limits Indexer is optimized for single-replica execution.