The Event Queue is a persistent SQLite-backed buffer that prevents event data loss during traffic spikes. Events are durably stored before being delivered to handlers, ensuring recovery even if the application crashes.
┌─────────────────────────────────────────────────────────────────┐
│ Soroban RPC │
└────────────────────────┬────────────────────────────────────────┘
│ getEvents()
▼
┌─────────────────────────────┐
│ EventPropagator │
│ (Poll every 5s) │
└────────────┬────────────────┘
│
│ enqueue(event)
▼
┌─────────────────────────────┐
│ EventQueue (SQLite) │
│ Persistent Buffer │
└────────────┬────────────────┘
│
│ dequeue() / process
▼
┌─────────────────────────────┐
│ Event Handlers │
│ • Dashboard WebSocket │
│ • Database Writer │
│ • Custom Handlers │
└─────────────────────────────┘
Each queued event has a state that tracks its lifecycle:
| State | Description | Transitions To |
|---|---|---|
| pending | Enqueued, awaiting handler processing | processing |
| processing | Currently being handled | processed or pending (retry) |
| processed | Successfully handled; eligible for cleanup | (terminal) |
| failed | Exhausted max retries | (terminal) |
┌─────────┐
│ pending │ (initial state when enqueued)
└────┬────┘
│ dequeue()
▼
┌───────────┐
│processing │ (in transit to handlers)
└───┬───────┘
│
├─────────────────────┐
│ │
│ markProcessed() │ markFailed()
│ (success) │ (handler error)
▼ ▼
┌──────────┐ ┌─────────┐
│processed │ │ pending │ (if retries available)
└──────────┘ └────┬────┘
(cleanup or │
after age) │ (no retries)
▼
┌─────────┐
│ failed │ (terminal)
└─────────┘
Table: events
CREATE TABLE events (
id TEXT PRIMARY KEY, -- Unique event ID from Soroban RPC
eventData TEXT NOT NULL, -- JSON-serialized EngineEvent
status TEXT NOT NULL, -- pending|processing|processed|failed
attempts INT NOT NULL DEFAULT 0, -- Number of processing attempts
enqueueTime INT NOT NULL, -- Milliseconds since epoch
processTime INT, -- Milliseconds since epoch (nullable)
error TEXT -- Last error message (nullable)
);
CREATE INDEX idx_status ON events(status);
CREATE INDEX idx_enqueueTime ON events(enqueueTime);Optimizations:
- WAL mode for concurrent reads during writes
- Synchronous = NORMAL for balance between durability and performance
- Indexes on status and enqueueTime for fast queue queries
import { EventPropagator, EventQueue } from "@vero/engine-bridge";
import { RpcClient } from "@vero/engine-bridge";
// Create RPC client
const rpc = new RpcClient([
"https://soroban-testnet.stellar.org",
"https://rpc-testnet.stellar.org",
]);
// Create propagator with queue (auto-initialized)
const propagator = new EventPropagator(
rpc,
CONTRACT_ID,
undefined, // cursor override
"./event-queue.db" // queue path (optional)
);
// Register handlers
propagator.onEvent(async (event) => {
// Event is guaranteed to be persisted before this is called
console.log("Processing event:", event.id);
// Send to dashboard, write to DB, etc.
});
// Recovery: process any pending events from crashes
await propagator.recoverPendingEvents();
// Start polling and processing
propagator.start();
// Query queue status
const stats = propagator.getQueueStats();
console.log(`Queue: ${stats.pending} pending, ${stats.processed} done`);import { EventQueue } from "@vero/engine-bridge";
const queue = new EventQueue("./event-queue.db", 3); // 3 max retries
// Enqueue an event
queue.enqueue(event);
// Process queue
const queued = queue.dequeue();
if (queued) {
try {
await handler(queued.eventData);
queue.markProcessed(queued.id);
} catch (err) {
queue.markFailed(queued.id, err as Error);
}
}
// Get statistics
const stats = queue.getStats();
console.log(`Total: ${stats.total}, Pending: ${stats.pending}, Failed: ${stats.failed}`);
// Cleanup old processed events (older than 24h)
const cleaned = queue.cleanup(24 * 60 * 60 * 1000);
console.log(`Deleted ${cleaned} old events`);
// Close database
queue.close();Requirement: Events are recovered after application crash or restart.
Verification: event-queue.test.ts
- Test:
recoverPendingEvents after queue recreation - Test:
recovers failed events with retries available - Test:
does not recover processed events
Implementation:
EventQueue.recoverPending()retrieves all pending and failed (with retries) eventsEventPropagator.recoverPendingEvents()processes recovered events on startup- SQLite WAL mode ensures durability
Requirement: Events are persisted to local database before handler execution.
Verification:
- Test:
enqueues an event successfully - Test:
dequeues pending event and transitions to processing - SQLite files (event-queue.db, -shm, -wal) present on disk
Implementation:
EventPropagator.fetchAndEnqueue()persists events viaqueue.enqueue()EventQueueuses SQLite with WAL for durability- Events written to disk before passing to handlers
Requirement: Queue buffer status and capacity are verifiable.
Verification:
- Test:
reports accurate queue statistics - Test:
calculates oldest event age EventQueue.getStats()returns current queue stateEventPropagator.getQueueStats()exposes stats
Implementation:
const stats = queue.getStats();
// Returns:
// {
// total: 42, // Total events in queue
// pending: 10, // Awaiting processing
// processing: 2, // Currently being handled
// processed: 30, // Successfully handled
// failed: 0, // Exhausted retries
// oldestEventAge: 1500 // ms since oldest event enqueued
// }Situation: Handler throws error during processing
Behavior:
- Event marked as
processing - Handler called, throws error
- Event transitions back to
pending(if retries available) - Event automatically retried on next process cycle
- After 3 retries (configurable), event marked
failed
Recovery: Manual inspection of failed events in database; potential replay after fix
Situation: Process crashes while events in queue
Behavior:
- Events in
pendingstate persist on disk (SQLite guarantees) - Events in
processingstate persist on disk - On restart,
recoverPendingEvents()processes them - Cursor position saved separately; event stream resumes from last known position
Recovery: Automatic on restart via recoverPendingEvents()
Situation: RPC returns 1000 events; handlers can't keep up
Behavior:
fetchAndEnqueue()enqueues all 1000 events to SQLite (fast)- Cursor advanced immediately (no data loss)
processQueue()works through backlog at handler's pace- Queue stats show
pending: 950,processing: 1 - System remains responsive; no blocking
Recovery: Automatic via steady-state processing; queue drains over time
Situation: SQLite disk space exhausted
Behavior:
queue.enqueue()fails, returnsfalse- Event not persisted; logged with warning
- Cursor not advanced
- On next poll, same events re-fetched
- If disk space freed, events enqueued successfully
Recovery: Admin must free disk space; normal operation resumes
new EventQueue(
dbPath = "./event-queue.db", // SQLite file path
maxRetries = 3 // Max processing attempts per event
)new EventPropagator(
rpc, // RpcClient instance
contractId, // Soroban contract ID
cursorOverride?: string, // Resume from specific cursor
queuePath?: string // Custom queue DB path
)POLL_INTERVAL_MS = 5_000; // Fetch from RPC every 5s
PROCESS_INTERVAL_MS = 1_000; // Process queue every 1sTuning:
- Increase
POLL_INTERVAL_MSfor lower RPC load - Decrease
PROCESS_INTERVAL_MSfor faster handler throughput (higher CPU) - Adjust
MAX_RETRIESfor different failure tolerance
const stats = propagator.getQueueStats();
const isHealthy =
stats.processing <= 1 && // Not backed up
stats.failed === 0 && // No exhausted retries
stats.oldestEventAge < 60_000; // No old events (< 60s)
if (!isHealthy) {
console.warn("Queue health degraded:", stats);
}const cleaned = queue.cleanup(24 * 60 * 60 * 1000);
console.log(`Cleanup: deleted ${cleaned} events older than 24h`);sqlite3 event-queue.db
> SELECT id, error, attempts FROM events WHERE status = 'failed' LIMIT 10;queue.deleteQueue(); // Closes and deletes all queue files| Operation | Time | Notes |
|---|---|---|
| Enqueue | O(1) | SQL INSERT + index update |
| Dequeue | O(log n) | SQL query with index, UPDATE to processing |
| Mark Processed | O(1) | Single row UPDATE |
| Mark Failed | O(log n) | Query + UPDATE with state check |
| Recovery | O(n) | Full table scan for pending |
| Cleanup | O(m) | WHERE status='processed' AND age > threshold |
Throughput: ~500-1000 events/sec per core (single thread)
Memory: Minimal; events stay on disk until dequeue
Disk: ~1KB per event (SQLite storage + JSON)
Comprehensive test suite in event-queue.test.ts:
- ✅ Enqueue and dequeue operations
- ✅ FIFO ordering
- ✅ State transitions
- ✅ Retry logic with max attempts
- ✅ Persistence and recovery
- ✅ Statistics accuracy
- ✅ Cleanup operations
Run tests:
npm test -- event-queue.test.ts- Database Initialization: EventQueue auto-creates tables on first run
- Disk Space: Allocate sufficient disk for peak load (1KB per event)
- Backups: Include
event-queue.db*files in backup strategy - Migration: Queue starts empty; old events not migrated
- Monitoring: Set up alerts for queue growth (pending count) and failed events
- Async batching of handlers (current: sequential with Promise.allSettled)
- Priority queue (high/normal/low) with different retry policies
- Dead-letter queue for permanently failed events
- Metrics export (Prometheus, CloudWatch)
- Compression of old processed events
- Distributed queue for multi-instance deployments
- ✅ No sensitive data in queue (events are application events only)
- ✅ SQLite file permissions should restrict read access (600)
- ✅ No automatic cleanup of failed events (manual inspection recommended)
⚠️ Consider encrypting queue file in production (external tool or LUKS)
Status: Implementation Complete
Last Updated: 2026-06-19
Test Coverage: Comprehensive (8 test suites, 18+ test cases)