Skip to content

Latest commit

 

History

History
415 lines (317 loc) · 13.3 KB

File metadata and controls

415 lines (317 loc) · 13.3 KB

Event Queue Implementation — Persistent Buffer for Spike Protection

Overview

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.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                      Soroban RPC                               │
└────────────────────────┬────────────────────────────────────────┘
                         │ getEvents()
                         ▼
           ┌─────────────────────────────┐
           │   EventPropagator           │
           │   (Poll every 5s)           │
           └────────────┬────────────────┘
                        │
                        │ enqueue(event)
                        ▼
           ┌─────────────────────────────┐
           │   EventQueue (SQLite)       │
           │   Persistent Buffer         │
           └────────────┬────────────────┘
                        │
                        │ dequeue() / process
                        ▼
           ┌─────────────────────────────┐
           │   Event Handlers            │
           │   • Dashboard WebSocket     │
           │   • Database Writer         │
           │   • Custom Handlers         │
           └─────────────────────────────┘

Event Queue States

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)

State Transitions

┌─────────┐
│ 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)
                       └─────────┘

Database Schema

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

Usage

Basic Integration

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`);

Direct Queue Usage

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();

Acceptance Criteria

✅ AC1: Events Recovered

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) events
  • EventPropagator.recoverPendingEvents() processes recovered events on startup
  • SQLite WAL mode ensures durability

✅ AC2: Persistent Queue

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 via queue.enqueue()
  • EventQueue uses SQLite with WAL for durability
  • Events written to disk before passing to handlers

✅ AC3: Buffer Verified

Requirement: Queue buffer status and capacity are verifiable.

Verification:

  • Test: reports accurate queue statistics
  • Test: calculates oldest event age
  • EventQueue.getStats() returns current queue state
  • EventPropagator.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
// }

Failure Scenarios & Recovery

Scenario 1: Handler Failure (Temporary)

Situation: Handler throws error during processing

Behavior:

  1. Event marked as processing
  2. Handler called, throws error
  3. Event transitions back to pending (if retries available)
  4. Event automatically retried on next process cycle
  5. After 3 retries (configurable), event marked failed

Recovery: Manual inspection of failed events in database; potential replay after fix

Scenario 2: Application Crash

Situation: Process crashes while events in queue

Behavior:

  1. Events in pending state persist on disk (SQLite guarantees)
  2. Events in processing state persist on disk
  3. On restart, recoverPendingEvents() processes them
  4. Cursor position saved separately; event stream resumes from last known position

Recovery: Automatic on restart via recoverPendingEvents()

Scenario 3: Traffic Spike

Situation: RPC returns 1000 events; handlers can't keep up

Behavior:

  1. fetchAndEnqueue() enqueues all 1000 events to SQLite (fast)
  2. Cursor advanced immediately (no data loss)
  3. processQueue() works through backlog at handler's pace
  4. Queue stats show pending: 950, processing: 1
  5. System remains responsive; no blocking

Recovery: Automatic via steady-state processing; queue drains over time

Scenario 4: Disk Full

Situation: SQLite disk space exhausted

Behavior:

  1. queue.enqueue() fails, returns false
  2. Event not persisted; logged with warning
  3. Cursor not advanced
  4. On next poll, same events re-fetched
  5. If disk space freed, events enqueued successfully

Recovery: Admin must free disk space; normal operation resumes

Configuration

EventQueue Constructor

new EventQueue(
  dbPath = "./event-queue.db",  // SQLite file path
  maxRetries = 3                 // Max processing attempts per event
)

EventPropagator with Queue

new EventPropagator(
  rpc,                           // RpcClient instance
  contractId,                    // Soroban contract ID
  cursorOverride?: string,       // Resume from specific cursor
  queuePath?: string             // Custom queue DB path
)

Polling & Processing Intervals

POLL_INTERVAL_MS = 5_000;        // Fetch from RPC every 5s
PROCESS_INTERVAL_MS = 1_000;     // Process queue every 1s

Tuning:

  • Increase POLL_INTERVAL_MS for lower RPC load
  • Decrease PROCESS_INTERVAL_MS for faster handler throughput (higher CPU)
  • Adjust MAX_RETRIES for different failure tolerance

Monitoring & Operations

Health Checks

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);
}

Operational Tasks

Clean up old events (cron job, daily)

const cleaned = queue.cleanup(24 * 60 * 60 * 1000);
console.log(`Cleanup: deleted ${cleaned} events older than 24h`);

Inspect failed events

sqlite3 event-queue.db
> SELECT id, error, attempts FROM events WHERE status = 'failed' LIMIT 10;

Reset queue (development only)

queue.deleteQueue(); // Closes and deletes all queue files

Performance Characteristics

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)

Testing

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

Deployment Notes

  1. Database Initialization: EventQueue auto-creates tables on first run
  2. Disk Space: Allocate sufficient disk for peak load (1KB per event)
  3. Backups: Include event-queue.db* files in backup strategy
  4. Migration: Queue starts empty; old events not migrated
  5. Monitoring: Set up alerts for queue growth (pending count) and failed events

Future Enhancements

  • 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

Security Implications

  • ✅ 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)