Skip to main content

Database Schema

Sagaweaw persists all state in PostgreSQL, MySQL 8+, or H2. Flyway automatically selects the correct migration based on the detected database ({vendor}). Here is the complete schema.


Entity Diagram

┌─────────────────┐ ┌─────────────────┐
│ sagas │───────│ saga_steps │
└─────────────────┘ └─────────────────┘

│ ┌─────────────────┐
├────────│ saga_events │
│ └─────────────────┘

│ ┌─────────────────┐
├────────│ outbox_messages │
│ └─────────────────┘

│ ┌─────────────────┐
└────────│ dead_letters │
└─────────────────┘
Horizontal scaling

Sagaweaw supports horizontal scaling — multiple instances sharing the same database with no extra coordination. Each saga records the instance that created it in the instance_id column.


Tables

sagas

Main table that stores the state of each saga in execution.

CREATE TABLE sagas (
id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
context_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
completed_at TIMESTAMP(6) WITH TIME ZONE,
idempotency_key VARCHAR(255),
version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);

CREATE UNIQUE INDEX idx_sagas_idempotency ON sagas (idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX idx_sagas_status ON sagas (status);
CREATE INDEX idx_sagas_name ON sagas (name);
CREATE INDEX idx_sagas_created_at ON sagas (created_at);
ColumnTypeDescription
idVARCHAR(36)Saga UUID
nameVARCHARSaga name (e.g. "pix-payment")
statusVARCHARSTARTED, EXECUTING, COMPLETED, COMPENSATING, COMPENSATED, FAILED
context_jsonJSONBSerialized context (immutable between steps)
versionINTEGERVersion for optimistic locking
idempotency_keyVARCHARKey to prevent duplicate execution

saga_steps

State of each step within a saga.

CREATE TABLE saga_steps (
id VARCHAR(36) NOT NULL,
saga_id VARCHAR(36) NOT NULL,
step_name VARCHAR(255) NOT NULL,
step_order INTEGER NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
attempt INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
next_retry_at TIMESTAMP(6) WITH TIME ZONE,
last_error TEXT,
error_trace TEXT,
input_payload JSONB,
output_payload JSONB,
executed_at TIMESTAMP(6) WITH TIME ZONE,
completed_at TIMESTAMP(6) WITH TIME ZONE,
duration_ms BIGINT,
PRIMARY KEY (id),
CONSTRAINT fk_saga_steps_saga FOREIGN KEY (saga_id) REFERENCES sagas (id)
);

CREATE INDEX idx_saga_steps_saga_id ON saga_steps (saga_id);
CREATE INDEX idx_saga_steps_retry ON saga_steps (status, next_retry_at);
ColumnTypeDescription
step_nameVARCHARStep name (e.g. "validate-dict")
step_orderINTEGERExecution order (0, 1, 2…)
statusVARCHARPENDING, EXECUTING, COMPLETED, FAILED, COMPENSATING, COMPENSATED
attemptINTEGERCurrent attempt
max_attemptsINTEGERMaximum attempts (enables "attempt X/Y" display)
next_retry_atTIMESTAMPWhen the next retry is scheduled
last_errorTEXTLast error message
error_traceTEXTFull stack trace of the last error
input_payloadJSONBSerialized context at step entry
output_payloadJSONBSerialized context at step exit (used by compensator)
duration_msBIGINTExecution duration in milliseconds

saga_events

Immutable event log for complete audit. Only receives INSERT, never UPDATE.

CREATE TABLE saga_events (
id VARCHAR(36) NOT NULL,
saga_id VARCHAR(36) NOT NULL,
step_name VARCHAR(255),
event_type VARCHAR(100) NOT NULL,
payload JSONB,
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_saga_events_saga FOREIGN KEY (saga_id) REFERENCES sagas (id)
);

CREATE INDEX idx_saga_events_saga_id ON saga_events (saga_id);
CREATE INDEX idx_saga_events_created ON saga_events (created_at);
ColumnTypeDescription
event_typeVARCHARSAGA_STARTED, STEP_STARTED, STEP_COMPLETED, STEP_FAILED, COMPENSATION_STARTED, etc.
payloadJSONBEvent data (error message, duration, etc.)

Query the event history of a saga via API:

GET /api/sagas/{id}/events

outbox_messages

Messages for the Transactional Outbox pattern. No FK on saga_id to support saga archiving.

CREATE TABLE outbox_messages (
id VARCHAR(36) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
step_name VARCHAR(255),
topic VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
published BOOLEAN NOT NULL DEFAULT FALSE,
publish_attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
published_at TIMESTAMP(6) WITH TIME ZONE,
PRIMARY KEY (id)
);

CREATE INDEX idx_outbox_unpublished ON outbox_messages (published, created_at);
ColumnTypeDescription
topicVARCHARTarget Kafka topic
headersJSONBAdditional message headers
publish_attemptsINTEGERPublication attempts
publishedBOOLEANWhether it has been published

dead_letters

Sagas that exhausted all attempts and need manual intervention. No FK on saga_id to persist the forensic data even after saga removal.

CREATE TABLE dead_letters (
id VARCHAR(36) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
step_name VARCHAR(255) NOT NULL,
error_message TEXT,
error_trace TEXT,
context_snapshot TEXT,
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
reprocessed BOOLEAN NOT NULL DEFAULT FALSE,
reprocessed_at TIMESTAMP(6) WITH TIME ZONE,
reprocessed_by VARCHAR(255),
PRIMARY KEY (id)
);

CREATE INDEX idx_dead_letters_saga_id ON dead_letters (saga_id);
CREATE INDEX idx_dead_letters_unreproc ON dead_letters (reprocessed, created_at);
ColumnTypeDescription
error_traceTEXTFull stack trace
context_snapshotTEXTSnapshot of the saga context at the time of failure
reprocessedBOOLEANWhether it was reprocessed via API or manually
reprocessed_byVARCHARIdentification of who reprocessed ("api" or user)

Migrations per Database

Sagaweaw uses Flyway with the {vendor} placeholder to automatically select the correct migration:

resources/
└── db/migration/sagaweaw/
├── postgresql/
│ └── V1__sagaweaw_schema.sql ← JSONB, TIMESTAMP(6) WITH TIME ZONE
├── mysql/
│ └── V1__sagaweaw_schema.sql ← JSON, DATETIME(6), TINYINT(1)
└── h2/
└── V1__sagaweaw_schema.sql ← JSON, TIMESTAMP WITH TIME ZONE

No extra configuration is needed — just point datasource.url to the desired database.


Next Steps