Skip to content

Latest commit

 

History

History
319 lines (219 loc) · 14.8 KB

schema.md

File metadata and controls

319 lines (219 loc) · 14.8 KB

Database Schema

This document describes the PostgreSQL schema used by Soroban Pulse. All tables are created and evolved through the migration files in migrations/.

Entity-Relationship Diagram

erDiagram
    events {
        uuid        id           PK  "gen_random_uuid()"
        text        contract_id  NK  "NOT NULL"
        text        event_type   NK  "NOT NULL"
        text        tx_hash      NK  "NOT NULL"
        bigint      ledger           "NOT NULL"
        timestamptz timestamp        "NOT NULL"
        jsonb       event_data       "NOT NULL"
        timestamptz created_at       "NOT NULL DEFAULT NOW()"
    }
Loading

The events table has no foreign keys — it is a self-contained append-only log of indexed Soroban events.


events Table

The central (and only) table. Each row represents one Soroban event emitted by a smart contract on the Stellar network.

Columns

Column Type Nullable Constraints Purpose
id UUID NOT NULL PRIMARY KEY, default gen_random_uuid() Surrogate primary key. Generated server-side; never supplied by the RPC. Used as the Last-Event-ID value in SSE streams for resumable connections.
contract_id TEXT NOT NULL Part of unique constraint Stellar contract address (56-character Strkey, always starts with C). Example: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM.
event_type TEXT NOT NULL Part of unique constraint, CHECK via application Soroban event category. One of contract, diagnostic, or system. Stored as plain text rather than a Postgres enum so that new types added by the Stellar protocol do not require a schema migration.
tx_hash TEXT NOT NULL Part of unique constraint SHA-256 hex digest of the transaction that emitted the event (64 lowercase hex characters). Example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.
ledger BIGINT NOT NULL — Ledger sequence number at which the event was emitted. Used for range queries and ordering.
timestamp TIMESTAMPTZ NOT NULL — Ledger close time reported by the RPC (ledgerClosedAt). Stored with timezone (UTC).
event_data JSONB NOT NULL CHECK constraint on structure Structured event payload. Always a JSON object with two keys: value (object or null) and topic (array or null). The CHECK constraint check_event_data_structure enforces this shape. Example: {"value": {"amount": 1000}, "topic": [{"sym": "swap"}]}.
created_at TIMESTAMPTZ NOT NULL Default NOW() Wall-clock time when the row was inserted by the indexer. Distinct from timestamp (ledger close time). Used for SSE replay queries (Last-Event-ID resumption).

Constraints

Primary Key

PRIMARY KEY (id)

id is a UUID generated by gen_random_uuid(). It is stable across re-indexing attempts because ON CONFLICT DO NOTHING is used on insert — the UUID is only assigned once, on first successful insert.

Unique Constraint — idx_events_tx_hash_contract

UNIQUE (tx_hash, contract_id, event_type)

Rationale: A single transaction can emit multiple Soroban events, potentially from different contracts and of different types. The combination (tx_hash, contract_id, event_type) is the natural deduplication key that matches the Stellar protocol's event identity. Using this as the conflict target for ON CONFLICT DO NOTHING makes the indexer idempotent — re-processing a ledger range (e.g. during a replay job) never produces duplicate rows.

A simpler PRIMARY KEY (tx_hash, contract_id, event_type) was not chosen because:

  • The UUID id is needed as a stable, opaque cursor value for SSE Last-Event-ID resumption.
  • Composite primary keys make foreign key references from future tables more verbose.

CHECK Constraint — check_event_data_structure

CHECK (
    (event_data->'value' IS NULL OR jsonb_typeof(event_data->'value') = 'object') AND
    (event_data->'topic' IS NULL OR jsonb_typeof(event_data->'topic') = 'array')
)

Enforces that event_data always has the expected shape. Prevents malformed payloads from being stored if the indexer or a replay job encounters unexpected RPC output.


Indexes

idx_events_contract_ledger (composite)

CREATE INDEX idx_events_contract_ledger ON events(contract_id, ledger DESC);

Optimises: GET /v1/events/contract/{contract_id} — filters by contract_id and sorts by ledger DESC. The composite index satisfies both the equality filter and the sort in a single index scan, avoiding a separate sort step.

idx_events_tx_ledger (composite)

CREATE INDEX idx_events_tx_ledger ON events(tx_hash, ledger DESC);

Optimises: GET /v1/events/tx/{tx_hash} — filters by tx_hash and sorts by ledger DESC. Same rationale as above.

idx_events_ledger_desc

CREATE INDEX idx_events_ledger_desc ON events(ledger DESC);

Optimises: GET /v1/events (paginated list) — the global events feed is always ordered by ledger DESC. A descending index avoids a full-table sort. The original ascending idx_events_ledger was dropped in migration 20260325000000 once all queries were confirmed to use ORDER BY ledger DESC.

idx_events_event_data_gin (GIN, CONCURRENTLY)

CREATE INDEX CONCURRENTLY idx_events_event_data_gin
    ON events USING GIN (event_data jsonb_path_ops);

Optimises: JSON containment queries on event_data using the @> operator (e.g. filtering by topic value). Built with CONCURRENTLY so it does not lock the table during creation. The migration file is marked -- no-transaction because CREATE INDEX CONCURRENTLY cannot run inside a transaction block.

idx_events_tx_hash_contract (unique)

UNIQUE INDEX idx_events_tx_hash_contract ON events(tx_hash, contract_id, event_type);

Serves dual purpose: enforces the deduplication constraint (see above) and supports fast lookups by (tx_hash, contract_id, event_type).


Migration History

File Description
20260314000000_create_events.sql Initial schema: events table, single-column indexes on contract_id, tx_hash, ledger, and the unique constraint.
20260325000000_optimize_ledger_index.sql Replace ascending idx_events_ledger with descending idx_events_ledger_desc.
20260325000001_composite_indices.sql Add composite indexes idx_events_contract_ledger and idx_events_tx_ledger; drop now-redundant single-column indexes.
20260424000000_gin_index_event_data.sql Add GIN index on event_data for JSON containment queries (no-transaction migration).
20260425000001_event_data_validation.sql Add check_event_data_structure CHECK constraint.

| 20260425000001_event_data_validation.sql | Add check_event_data_structure CHECK constraint. |

File Description
20260428000002_matview_daily_summary.sql Create events_daily_summary materialized view and its unique index.
20260428000003_matview_contract_summary.sql Create events_contract_summary materialized view and its unique index.
20260428000004_matview_hourly_volume.sql Create events_hourly_volume materialized view and its unique index.

Materialized Views

Three materialized views pre-compute aggregations over the events table. They are refreshed every 5 minutes (configurable via STATS_REFRESH_INTERVAL_SECS) by a background task using REFRESH MATERIALIZED VIEW CONCURRENTLY, which does not lock the view for reads.

Each view has a UNIQUE index — required by PostgreSQL for CONCURRENTLY refresh.

events_daily_summary

Pre-computes event counts grouped by calendar date and event type.

SELECT DATE(timestamp) AS event_date, event_type, COUNT(*) AS event_count
FROM events
GROUP BY DATE(timestamp), event_type;
Column Type Description
event_date DATE Calendar date of the events (part of unique key)
event_type TEXT Event type: contract, diagnostic, or system (part of unique key)
event_count BIGINT Number of events on that date with that type

Used by: GET /v1/events/stats — per-type totals and 24h/7d windowed counts.

events_contract_summary

Pre-computes total event count and latest ledger per contract.

SELECT contract_id, COUNT(*) AS event_count, MAX(ledger) AS latest_ledger
FROM events
GROUP BY contract_id;
Column Type Description
contract_id TEXT Stellar contract address (unique key)
event_count BIGINT Total events emitted by this contract
latest_ledger BIGINT Highest ledger sequence seen for this contract

Used by: GET /v1/events/stats — top 10 contracts by event count.

events_hourly_volume

Pre-computes event counts per hour for the last 7 days.

SELECT DATE_TRUNC('hour', timestamp) AS event_hour, COUNT(*) AS event_count
FROM events
WHERE timestamp >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('hour', timestamp);
Column Type Description
event_hour TIMESTAMPTZ Hour bucket (truncated to the hour, unique key)
event_count BIGINT Number of events in that hour

Note: Because the WHERE clause uses NOW() at view-creation time, the view must be refreshed regularly to keep the 7-day window current. The background refresh task handles this automatically.

Refresh Background Task

A Tokio task (src/stats_refresh.rs) runs at startup and then on a configurable interval:

STATS_REFRESH_INTERVAL_SECS=300   # default: 5 minutes

It issues REFRESH MATERIALIZED VIEW CONCURRENTLY for each view in sequence. Failures are logged as errors but do not crash the service — the views simply serve slightly stale data until the next successful refresh.

Lock Timeout Behaviour

Each materialized view refresh acquires a dedicated pool connection, sets lock_timeout = '5s', and resets it before returning the connection. If a concurrent long-running query holds a conflicting lock, the refresh is skipped (a WARN is logged) and retried on the next scheduled interval. This prevents a stuck refresh from blocking the connection pool or cascading into API failures.

Metrics emitted per refresh cycle:

  • soroban_pulse_matview_refresh_duration_seconds{view} — histogram of successful refresh latency.
  • soroban_pulse_matview_refresh_timeout_total{view} — counter incremented each time a lock timeout causes a skip.

Index Monitoring

The background task in src/index_monitor.rs runs on every cycle (INDEX_CHECK_INTERVAL_HOURS, default 24 h) and performs two checks:

  1. EXPLAIN-based checks — runs EXPLAIN (FORMAT JSON) on representative queries and warns if the query planner falls back to a sequential scan instead of the expected index.

  2. pg_stat_user_indexes scan counts — queries pg_stat_user_indexes and emits per-index scan counts as Prometheus metrics:

    • soroban_pulse_unused_indexes_total — gauge reporting how many public-schema indexes have idx_scan = 0 since the last statistics reset.
    • soroban_pulse_index_scan_count{table, index} — gauge reporting idx_scan for each monitored index.

A Prometheus alert (UnusedIndexesDetected) fires when soroban_pulse_unused_indexes_total > 0 for more than 24 hours. Unused indexes waste write throughput and storage; the alert prompts operators to review and drop obsolete indexes.

Note: idx_scan resets when pg_stat_reset() is called or the PostgreSQL instance is restarted. A newly created index will show idx_scan = 0 until it is first used; allow one full monitoring cycle before treating it as unused.


Migration Consolidation History

The following indexes were dropped as part of issue #804 (migration 20260728000001_index_consolidation.sql):

Dropped index Reason Surviving replacement
idx_events_contract_ledger Superseded by idx_events_contract_type_ledger which covers the same (contract_id, ledger DESC) pattern and additionally supports event_type filters idx_events_contract_type_ledger
idx_events_event_data_topic_gin Covered by idx_events_event_data_gin (full-document jsonb_path_ops) and the per-position idx_events_topic_1/2/3_gin indexes idx_events_event_data_gin, idx_events_topic_1_gin, idx_events_topic_2_gin, idx_events_topic_3_gin

All surviving indexes now carry a COMMENT describing their purpose (see the migration file for the full text).

A full audit of all 60+ migrations is documented in docs/schema-audit.md.


Partition Maintenance

The events table is partitioned by timestamp (monthly ranges) as of migration 20260727000002_partition_events_by_month.sql.

Pre-creating future partitions

The create_future_partitions(N) SQL function (defined in scripts/manage_partitions.sql) creates N months of partitions starting from the current month:

-- Create partitions for the next 3 months (run as needed or via cron)
SELECT create_future_partitions(3);

The schema health check (src/index_monitor.rs::run_schema_health_check) emits a WARN log and increments soroban_pulse_schema_missing_future_partitions if fewer than 2 future months are pre-created. Alert on this gauge being > 0 for more than 48 hours.

Verifying partition pruning

Partition pruning is enabled by default in PostgreSQL 12+. Confirm:

SHOW enable_partition_pruning;  -- should be 'on'

-- A query with a timestamp predicate should show only the relevant partition:
EXPLAIN SELECT id FROM events
WHERE timestamp >= '2026-07-01' AND timestamp < '2026-08-01'
ORDER BY ledger DESC LIMIT 20;
-- Expected: only events_2026_07 appears in the plan

Queries filtered only by contract_id (no timestamp) will scan all partitions but use the per-partition idx_events_contract_type_ledger index to limit rows.

Dropping old partitions

Use drop_old_partitions(N) to remove partitions older than N months:

-- Drop partitions older than 12 months (data retention policy)
SELECT drop_old_partitions(12);

Always take a backup before dropping partitions in production. Refer to docs/data-retention.md for the full retention policy.

events_legacy table

events_legacy is the original non-partitioned events table preserved during the partitioning migration. It is not referenced by any application code (grep of src/**/*.rs returns zero matches). After confirming that all historical data has been migrated to the partitioned events table, it can be dropped:

-- Verify data completeness first:
SELECT COUNT(*) FROM events_legacy;
SELECT COUNT(*) FROM events;
-- Then drop when counts match and data is confirmed migrated:
DROP TABLE events_legacy;