Outpost's data model is split across two stores. Redis holds configuration and short-lived coordination state. The log store, PostgreSQL or ClickHouse, holds the event and delivery history.

This page describes every table and key Outpost writes, so you can size, back up, inspect, and debug a self-hosted deployment. Treat it as a read-only reference: Outpost owns these structures, and its migrations may change them between releases.

Overview

StoreWhat it holdsLifetimeWritten by
RedisTenants, destinations and their credentialsPermanent until deletedAPI service
RedisRetry schedule (RSMQ)Until the retry runsDelivery service schedules, API service runs
RedisShort-lived processing stateTTL-bound, minutes to a dayAll services
RedisInstallation, migration, and lock metadataPermanent, except locks, which expireAll services, migration tool
PostgreSQL or ClickHouseEvents and delivery attemptsUntil retention removes themLog service

The stores don't share foreign keys. They're joined by ID in application code:

Redis                                         Log store (PostgreSQL / ClickHouse)
─────                                         ───────────────────────────────────
tenant:{<tenant_id>}:tenant          1 ──┐
                                         │ 1
tenant:{<tenant_id>}:destinations ◄──────┤    events
  (summary hash, one field per           │      id / event_id ───────────┐ 1
   destination)                          │      tenant_id                 │
                                         │ N    matched_destination_ids   │
tenant:{<tenant_id>}:destination:<id> ◄──┘                                │ N
                         ▲                    attempts                    │
                         └─────────────────────  destination_id           │
                                                 event_id ◄───────────────┘
                                                 tenant_id
  • A tenant owns many destinations. Both live in Redis under the same {<tenant_id>} hash tag.
  • An event belongs to one tenant and records the IDs of the destinations it matched when it was published.
  • A delivery attempt belongs to one event and one destination. A single event has one attempt per matched destination per try, including retries and manual retries.
  • Log rows reference tenants and destinations by ID only. Deleting a tenant or destination in Redis leaves its history in the log store until retention removes it.

Log store

The log store holds two tables, events and attempts, in both backends. Each attempt row embeds a copy of its event's fields, so listing attempts never needs a join. The trade-off is that event payloads are stored once per attempt as well as once in events.

An event is written together with its first delivery attempt. An event that matches no destination produces no attempt and isn't written. See Event & Delivery Log for retention and backend selection.

PostgreSQL

Both tables are range partitioned on time. Outpost creates only a DEFAULT partition for each (events_default and attempts_default) and doesn't manage further partitions.

events table

One row per published event that matched at least one destination.

ColumnTypeDescription
idtextEvent ID.
tenant_idtextTenant the event was published for.
matched_destination_idstext[]Destinations the event matched when it was published.
timetimestamptzEvent time. Partition key.
topictextEvent topic.
eligible_for_retrybooleanWhether failed attempts are retried automatically.
datatextEvent payload, stored as the raw JSON string so key order is preserved.
metadatajsonbEvent metadata.

Primary key: (time, id).

IndexColumnsUsed for
idx_events_tenant_timetenant_id, time DESC, id DESCListing a tenant's events.
idx_events_tenant_topic_timetenant_id, topic, time DESC, id DESCFiltering by topic.
idx_events_ididRetrieving an event by ID.
GINmatched_destination_idsFiltering events by destination.

attempts table

One row per delivery attempt. Event fields are copied onto every attempt.

ColumnTypeDescription
idtextAttempt ID.
event_idtextEvent this attempt delivered. References events.id.
tenant_idtextTenant ID, copied from the event.
destination_idtextDestination the attempt was made against.
destination_typetextDestination type, such as webhook or aws_sqs.
topictextEvent topic, copied from the event.
statustextsuccess or failed.
timetimestamptzAttempt time. Partition key.
attempt_numberintegerAttempt number for this event and destination.
manualbooleanWhether the attempt was a manual retry.
codetextResponse code from the destination, such as an HTTP status.
response_datatextDestination response, as a JSON string.
latency_msintegerTime the destination took to respond. NULL for attempts written before v1.6.
event_timetimestamptzEvent time, copied from the event.
eligible_for_retrybooleanCopied from the event.
event_datatextEvent payload, copied from the event.
event_metadatajsonbEvent metadata, copied from the event.

Primary key: (time, id).

IndexColumnsUsed for
idx_attempts_tenant_timetenant_id, time DESC, id DESCListing a tenant's attempts.
idx_attempts_tenant_dest_timetenant_id, destination_id, time DESC, id DESCFiltering by destination.
idx_attempts_tenant_desttype_timetenant_id, destination_type, time DESC, id DESCFiltering by destination type.
idx_attempts_tenant_status_timetenant_id, status, time DESC, id DESCFiltering by status.
idx_attempts_tenant_topic_timetenant_id, topic, time DESC, id DESCFiltering by topic.
idx_attempts_event_timeevent_id, time DESC, id DESCListing the attempts for an event, and loading the event for a retry.
idx_attempts_ididRetrieving an attempt by ID.

schema_migrations table

Managed by the migration tool to track the applied schema version. Don't edit it by hand; use the migration tool.

ClickHouse

Both tables use the ReplacingMergeTree engine. Rows with the same sorting key are deduplicated during background merges, so a retried insert can briefly appear twice before the merge runs. Both tables are partitioned by month.

When CLICKHOUSE_LOG_RETENTION_TTL_DAYS is set, Outpost applies it as a table TTL on event_time for events and attempt_time for attempts.

events table

One row per published event that matched at least one destination.

ColumnTypeDescription
event_idStringEvent ID.
tenant_idStringTenant the event was published for.
matched_destination_idsArray(String)Destinations the event matched when it was published.
topicStringEvent topic.
eligible_for_retryBoolWhether failed attempts are retried automatically.
event_timeDateTime64(3)Event time.
metadataStringEvent metadata, as a JSON string.
dataStringEvent payload, as the raw JSON string.
  • Partition: toYYYYMM(event_time)
  • Order by: (event_time, event_id)
  • Skip indexes: bloom filters on tenant_id, event_id, topic, and matched_destination_ids.

attempts table

One row per delivery attempt. Event fields are copied onto every attempt.

ColumnTypeDescription
event_idStringEvent this attempt delivered. References events.event_id.
tenant_idStringTenant ID, copied from the event.
destination_idStringDestination the attempt was made against.
destination_typeStringDestination type, such as webhook or aws_sqs.
topicStringEvent topic, copied from the event.
eligible_for_retryBoolCopied from the event.
event_timeDateTime64(3)Event time, copied from the event.
metadataStringEvent metadata, copied from the event.
dataStringEvent payload, copied from the event.
attempt_idStringAttempt ID.
statusStringsuccess or failed.
attempt_timeDateTime64(3)Attempt time.
codeStringResponse code from the destination, such as an HTTP status.
response_dataStringDestination response, as a JSON string.
manualBoolWhether the attempt was a manual retry.
attempt_numberUInt32Attempt number for this event and destination.
latency_msNullable(UInt32)Time the destination took to respond. NULL for attempts written before v1.6.
  • Partition: toYYYYMM(attempt_time)
  • Order by: (attempt_time, attempt_id)
  • Skip indexes: bloom filters on tenant_id, destination_id, destination_type, event_id, attempt_id, and topic; a set index on status.

schema_migrations table

Managed by the migration tool to track the applied schema version. Don't edit it by hand; use the migration tool.

Redis

Redis is the source of truth for tenant and destination configuration. Back it up and enable RDB or AOF persistence. Losing it loses your tenants, destinations, and pending retries; the log store can't rebuild them.

The {<tenant_id>} braces in tenant keys are a Redis Cluster hash tag. They place all of a tenant's keys in the same slot, which lets Outpost update a tenant and its destinations in a single transaction on clustered Redis.

Tenants and destinations

KeyTypeTTLContents
tenant:{<tenant_id>}:tenantHashNoneThe tenant record.
tenant:{<tenant_id>}:destinationsHashNoneDestination summaries, one field per destination ID.
tenant:{<tenant_id>}:destination:<destination_id>HashNoneThe full destination record.
tenant_idxRediSearch indexNoneIndex over tenant hashes for listing tenants.

Tenant hash fields: id, entity (always tenant), created_at, updated_at, metadata (JSON), and deleted_at once deleted. Timestamps are Unix milliseconds.

Destination hash fields: id, entity (always destination), type, topics (JSON), config (JSON), credentials, delivery_metadata, metadata (JSON), filter (JSON), created_at, updated_at, disabled_at when disabled, and deleted_at once deleted. credentials and delivery_metadata are encrypted with AES_ENCRYPTION_SECRET.

Destination summary hash: each field is a destination ID and each value is a JSON object with the destination's id, type, topics, filter, and disabled flag. Outpost matches published events against this hash, so routing reads one key per tenant rather than one per destination. It's kept in sync with the destination hashes on every write.

Deletion is a soft delete. Deleting a destination removes its field from the summary hash, sets deleted_at on the destination hash, and expires that hash after 7 days. Deleting a tenant does the same for every destination, removes the summary hash, and sets deleted_at with a 7 day expiry on the tenant hash. Recreating a tenant with the same ID within those 7 days clears deleted_at and the expiry.

tenant_idx is created at startup only when the Redis deployment supports RediSearch (FT.* commands). It indexes keys under the tenant: prefix whose entity field is tenant. Without it, the list tenants endpoint isn't available.

Retry schedule

Scheduled retries are stored in an RSMQ queue inside Redis, under the rsmq: namespace.

KeyTypeContents
rsmq:QUEUESSetNames of the queues Outpost has created.
rsmq:deliverymq-retrySorted setPending retry IDs, scored by the time each becomes due.
rsmq:deliverymq-retry:QHashQueue settings, plus each retry's body and receive counters.
rsmq:deliverymq-retry-dlqSorted setRetries that failed to be processed after 5 receives.
rsmq:deliverymq-retry-dlq:QHashBodies for dead-lettered retries.

Each retry's ID is derived from its event ID and destination ID, so there is at most one pending retry per event and destination. Scheduling a new retry, or making a manual retry, replaces or cancels the pending one.

A retry body holds only the event ID, tenant ID, and destination ID. When the retry is due, Outpost loads the event and the previous attempt number from the log store's attempts table, then publishes a new delivery task. Dead-lettered retries aren't redriven automatically.

Short-lived processing state

These keys are short-lived and self-expiring, and safe to lose. Losing an idempotency key can cause a duplicate delivery. Losing an alert:…:cf key resets the destination's consecutive-failure count, which delays threshold alerts and auto-disable.

KeyTypeTTLPurpose
idempotency:publishmq:<event_id>StringPUBLISH_IDEMPOTENCY_KEY_TTL (default 1 hour)Deduplicates published events. Holds processing during work, then processed.
idempotency:deliverymq:<event_id>:<destination_id>:<attempt>StringDELIVERY_IDEMPOTENCY_KEY_TTL (default 1 hour)Deduplicates delivery tasks, so each attempt is made once.
publishmq:failures:<message_id>String (counter)24 hoursCounts failed receives of a message from the publish queue.
alert:<tenant_id>:<destination_id>:cfSet24 hoursIDs of consecutive failed attempts for a destination. Its size is the consecutive failure count; a successful attempt deletes it.
logmq:processed:<attempt_id>String24 hoursMarks a failed attempt as processed for alerting, so a redelivered log message isn't counted twice.
opevents:exhausted:<tenant_id>:<destination_id>StringALERT_EXHAUSTED_RETRIES_WINDOW_SECONDS (default 1 hour)Limits exhausted-retries alerts to one per destination per window.

Deployment metadata

KeyTypeTTLPurpose
outpost:installation_idStringNoneAnonymous installation ID used for telemetry. Only created when telemetry is enabled.
outpost:log_retention_ttlStringNoneThe ClickHouse retention TTL, in days, that was last applied. Used to skip reapplying an unchanged value at startup.
outpost:migration:<name>HashNoneStatus of a Redis data migration: status (applied or not_applicable) and a timestamp.
.outpost:migration_lockString1 hourHeld while migrations run, so only one runner applies them.
outpost:lockString60 secondsHeld while message queue infrastructure is provisioned at startup.

Lifecycle of an event across stores

Following one event through the system shows how the stores relate:

  1. Publish. The API service, or the publish queue consumer, claims idempotency:publishmq:<event_id>. It reads tenant:{<tenant_id>}:destinations to find the destinations that match the event's topic and filters, then enqueues one delivery task per destination on the delivery queue.
  2. Deliver. The delivery service claims idempotency:deliverymq:<event_id>:<destination_id>:<attempt> and reads tenant:{<tenant_id>}:destination:<destination_id> for the destination's config and decrypted credentials. It makes the attempt and sends the result to the log queue. On a retryable failure, it also schedules a retry in rsmq:deliverymq-retry.
  3. Log. The log service writes the event to events, if it isn't there yet, and the attempt to attempts, in batches. For failed attempts, it updates alert:<tenant_id>:<destination_id>:cf and may emit operator events or disable the destination. A successful attempt resets the count.
  4. Retry. When a retry is due, the API service, which runs the retry scheduler, reads the event and the latest attempt from attempts, then enqueues a new delivery task with the next attempt number, which returns to step 2.

Because step 4 reads from the log store, a retry can only run once the previous attempt has been written there. A retry that finds no prior attempt waits and tries again.