hermod

package module
v1.0.0-rc.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 7, 2026 License: MIT Imports: 5 Imported by: 0

README

Hermod

Real-time Postgres CDC to 40+ destinations, with a visual pipeline editor. One Go binary, self-hosted, running locally in about a minute.

No JVM, no Kafka cluster required, no per-row cloud bill. ./scripts/dev.sh brings up Postgres, the API, a worker and the UI, and completes first-run setup for you.

Where it fits: managed ELT (Fivetran) and open-source ELT (Airbyte) are batch-scheduled and measure freshness in minutes. Real-time CDC services (Estuary) are sub-second but not self-hostable. Stream processors (Redpanda Connect, NiFi) have no visual pipeline editor or need a JVM. Hermod is the self-hosted overlap: sub-second Postgres CDC, in-pipeline windowing and transforms, and a DAG you can see.

If you need 600 connectors, use Airbyte. If you need durable multi-day workflow orchestration, use Temporal. If you need one binary that tails a Postgres WAL and lands it somewhere else, in order and without a cluster, that is this.

Maturity, up front

Hermod covers a lot of surface, and that surface is not uniformly deep. Connectors are tiered — GA, Beta, Experimental — in Connector maturity tiers. Read that table before picking a connector for production. Anything not listed as GA has not been tested against live infrastructure.

Delivery is at-least-once with sink-side idempotency for duplicate suppression. Where this document says a guarantee is scoped or unfinished, that is the literal state of the code, not modesty.

Initial load is available for PostgreSQL, MySQL and MongoDB, and off by default. Set initial_load: "true" on one of those CDC sources and the rows already in the watched tables are carried across before streaming begins.

PostgreSQL is consistent rather than approximate. The replication slot is created over the replication protocol so that it exports a snapshot, the backfill reads at exactly that snapshot, and streaming starts from the slot's consistent point — so nothing committed before the boundary is missed and nothing is read twice at it.

MySQL and MongoDB are gapless but not snapshot-isolated. Neither offers Postgres's exported-snapshot handshake, so the boundary is pinned by position instead: the binlog coordinates (MySQL) or the cluster time (MongoDB) are taken before the tables are read, and streaming resumes from there. Nothing committed during the backfill is lost, but a row changed while it runs arrives twice — once as it was, once as it became. That is the ordinary at-least-once bargain and sink-side idempotency collapses it; see Delivery guarantee.

Each source keeps its own record of having run, so the backfill is once-only and turning the flag on for a workflow that is already streaming does nothing. For PostgreSQL the record is the replication slot itself — if it exists, changes have already streamed from it and the rows are downstream — so enabling the flag takes effect only once the slot is dropped. MySQL and MongoDB leave nothing server-side that could serve the same purpose, and their stream positions cannot stand in either: a backfill moves no binlog position and produces no resume token, so a table carried across and then never written to would be indistinguishable from one that had never run. They record completion in the source state the engine persists on every ack (initial_load: done), alongside the position.

Off by default deliberately: enabling it for every existing workflow would re-read every source table the first time a position was reset, which is the opposite of what an upgrade should do. The remaining CDC sources still have no initial load — for those, a backfill and a stream have to be sequenced by hand, position first, and the duplicates that ordering produces are what sink-side idempotency is for.


Enterprise Data Platform Features

Hermod is built for mission-critical enterprise data workloads, providing robust features for governance, reliability, and observability:

  • Two-Phase Commit (2PC) across a transactional sink group: A group of sinks commits atomically — either every member applied the batch or none did — and the guarantee survives a crash, because the coordinator's decision is durable before any member is told about it. Recovery uses presumed abort, and a reaper rolls back anything left in doubt past a deadline, which is what keeps an interrupted round from holding PostgreSQL locks indefinitely. Scope, stated plainly: only the Postgres sink can participate today, so the realistic shape is two or more Postgres sinks kept consistent; a group containing a sink that cannot take part is refused at construction rather than silently degraded. Kafka does not participate — it previously carried no-op stubs that would have reported a failed rollback as a success. See Distributed transactions, including the operational hazard note, before enabling.
  • SSO & OpenID Connect (OIDC): Support for centralized identity providers like Okta, Auth0, and Azure AD for platform-wide authentication and RBAC.
  • Vector Database Sinks: Built-in support for Pinecone, Milvus, and pgvector to power enterprise AI knowledge bases and RAG pipelines.
  • Hermod CLI (hermodctl): A powerful terminal-based tool for workflow linting, secret management, and real-time remote monitoring.
  • Global Schema Registry: Enforce data contracts with built-in JSON Schema, Avro, and Protobuf support. Automatically tracks schema versions and ensures backward compatibility.
  • Workflow Versioning & Rollback: Every change to a workflow is automatically versioned. One-click rollback allows you to quickly revert to a known stable configuration.
  • Distributed State & Coordination: Native support for Redis and Etcd backends for consistent state management across multiple worker instances.
  • Enterprise Secret Management: Securely resolve credentials from HashiCorp Vault, AWS Secrets Manager, and Azure Key Vault using the secret:key prefix.
  • Role-Based Access Control (RBAC): Granular permissions for Administrators, Editors, and Viewers, including VHost-level isolation.
  • Message Tracing & Visual Journey: Visualize the exact path of a single message through the DAG, including latency and data mutations at each step.
  • Pipeline Health Heatmaps: Real-time visualization of throughput and error rates directly on the workflow canvas.
  • WebAssembly (WASM) Transformations: Run custom business logic written in Go, Rust, or C++ at near-native speed within the pipeline.
  • Automated PII Scanning: Built-in mask transformation detects and redacts sensitive data (PII/PHI) using a sophisticated regex-based discovery engine.
  • Audit Logging: Complete history of administrative changes and system events for security and compliance audits.
  • At-least-once delivery with exactly-once effects at the sink: Messages are acknowledged only after successful delivery, using the Transactional Outbox pattern for SQL sources. Combined with sink-side idempotency keys (see Idempotency and Exactly-Once Effects), a duplicate delivery does not produce a duplicate row. This is not end-to-end exactly-once delivery: the transport is at-least-once and duplicates are suppressed where they land, which is the same guarantee most of this category actually ships.
  • Sequential Control Flow: Explicitly chain sinks and transformations sequentially. Supports "Sinks as Transformers" by returning data from a sink back into the workflow pipeline.
  • Stateful Event Correlation (Join/Zip): Wait for and join messages from multiple sources based on a common key before downstream delivery.
  • Circuit Breaker & Failure Recovery: Protect downstream systems with a built-in Circuit Breaker node. Automatically routes messages to failure branches when error thresholds are exceeded.
  • Interactive Workflow Debugger: Real-time message pausing, stepping, and inspection directly from the UI. Pause messages at any node to inspect payload state and resume processing when ready.
  • Visual Lineage with Data Diffs: Enhanced message tracing with "Before and After" snapshots for every transformation node in the DAG. Visually debug exactly how data is mutated at each step.
  • AIOps & Self-Healing Optimization: AI-driven performance tuning that automatically adjusts concurrency, batch sizes, and retry policies based on real-time throughput and error patterns.
  • Intelligent Data Quality Alerts: Automated detection of data quality drift using the governance.Scorer. Alerts trigger when schema adherence or DQ scores drift from historical averages.
  • Global Mesh Governance (experimental): A cluster registry and an HTTP forwarding sink, letting one Hermod instance route messages to another. Scope check: it is roughly 320 lines, Failover marks a cluster's status and logs rather than redirecting traffic, and there are no health checks, retries or partition handling. Treat it as a foundation, not a control plane.
  • WASM & Blueprint Marketplace: Discover, version, and share custom WebAssembly transformers and Go-based workflow blueprints via an integrated Marketplace.
  • Legacy connectivity via incremental polling (Oracle, IBM DB2): Sources for Oracle and IBM DB2 that track a monotonically increasing key and emit new rows. To be precise about what this is and is not: it is watermark polling, not log-based CDC — there is no LogMiner or journal reader. It therefore sees inserts only; updates and deletes to existing rows are invisible, and every message is emitted as a create. Query construction goes through sqlutil.BuildIncrementalQuery, which applies each dialect's row limit after the sort (Oracle's ROWNUM is evaluated before ORDER BY, so it is applied to an ordered subquery — getting this wrong silently skips rows).
  • Advanced Database Mapping & Schema Discovery: Production-ready column mapping for all major databases (Postgres, MySQL, MSSQL, Oracle, Snowflake, MongoDB, etc.). Supports automatic table creation, identity/auto-increment columns, and "Smart Mapping" from both source and sink schemas.
  • Resource-Aware Sharding: Advanced worker sharding using Rendezvous hashing weighted by real-time CPU/Memory metrics. Ensures optimal workload distribution and prevents workflow flapping with built-in hysteresis.
  • AI-Native Transformations: Integrated "Cognitive ETL" nodes for AI Enrichment and AI Mapping, supporting OpenAI and local Ollama models. Includes AI Mapping Suggestions that automatically propose fixes for schema mismatches.
  • Auto-Parallelization & Node-Level Backpressure: The engine automatically detects independent branches in your DAG and executes them in parallel. Built-in node-level backpressure ensures that slow sinks or transformations don't block healthy execution paths.
  • Self-Healing "Safe Mode" & Anomaly Detection: Automatically detects processing anomalies (e.g., latency spikes) and can enter a "Safe Mode" during critical failures, diverting traffic to a Dead Letter Sink to preserve data and maintain system stability.
  • Stall Detection & Automatic Recovery: A wedged pipeline is detected and rebuilt without an operator. Two independent watchdogs cover the two ways a pipeline can wedge — work outstanding that never completes, and a CDC stream that stops being served (detected against the source's own wal_sender_timeout keepalive cadence). Recovery is a supervised restart with an exponential backoff, jitter, and a bounded restart budget, so a genuinely broken sink escalates to a human instead of looping. Nothing is lost: un-acknowledged changes replay from the replication slot's confirmed position.
  • Generic Protobuf Source: A universal source that can dynamically load .proto files at runtime, providing type-safe ingestion for any Protobuf-based service without recompilation.
  • Stateful Windowing: Support for Tumbling, Sliding, and Session windows in aggregation nodes, enabling complex real-time analytics and time-series processing directly in the pipeline.
  • Hot-Reloading for Scripts: Transformation logic in WASM or Lua can be updated on-the-fly. The engine detects changes and reloads the logic without requiring a workflow restart.
  • OpenTelemetry (OTLP) Native: Built-in support for exporting internal traces and metrics to standard enterprise observability stacks via the OTLP protocol.
  • Enterprise Connectivity: Native, optimized connectors for high-scale enterprise sinks like Snowflake.

Hermod works by reading data from a Source, buffering it in a high-performance buffer (in-memory RingBuffer or persistent FileBuffer), and then writing it to a Sink. This architecture allows it to handle peak loads and provide a flexible way to connect different databases to various message streams.

[Source] -> [Buffer] -> [Sink]
   ^           |          ^
   |        [Engine]      |
   +-----------+----------+

Install

The current release is v1.0.0-rc.1 — a release candidate. It is tested and the gates are green, but it has not yet run in a production deployment. See CHANGELOG.md for what changed, what breaks, and the known gaps.

Two things follow from it being a candidate, and both bite if you assume otherwise:

  • There is no :latest image. It is withheld from any pre-release, so docker pull ghcr.io/gsoultan/hermod:latest returns a 404 rather than quietly giving you something older.
  • Helm needs an explicit --version. Without one it reports could not locate a version matching provided version string, because a pre-release is not what a bare pull resolves to.
Container image
docker pull ghcr.io/gsoultan/hermod:1.0.0-rc.1
docker run --rm ghcr.io/gsoultan/hermod:1.0.0-rc.1 -version
# hermod v1.0.0-rc.1 (Enterprise Edition)

linux/amd64 and linux/arm64 are both published under that one tag.

Helm
helm install hermod oci://ghcr.io/gsoultan/charts/hermod \
  --version 1.0.0-rc.1 \
  --set existingSecret=hermod-master-key
Binaries

.deb, .rpm, .apk and tarballs for Linux amd64/arm64 are attached to the release.

From source
git clone https://github.com/gsoultan/hermod && cd hermod
go build ./cmd/hermod

Go 1.27 or later is required.

go get does not work, and is not expected to

go get github.com/gsoultan/hermod and go install github.com/gsoultan/hermod/cmd/hermod@… fail at this version. Use the image, the chart, a packaged binary, or a checkout.

Every version the Go module proxy can serve for this path is withdrawn and retracted — the range [v1.0.0, v1.8.0] plus v1.0.0-rc.1 by name, carried by the v1.8.0 tombstone tag. The proxy is immutable, so the releases deleted in this reset still answer there — and v1.0.0 in particular is permanently bound to a commit from February whose go.mod declared github.com/user/hermod, a path matching no repository. Re-tagging cannot displace it.

Nothing is lost that previously worked: the module path was a placeholder until 2026-09-02, so no published release was ever importable. Restarting the numbering at 1.0.0 was chosen over a go get path that has never functioned. It starts working once the line passes v1.7.4.

Usage

Hermod CLI (hermodctl)

Hermod provides a professional CLI tool for developers and operators to manage the platform from the terminal.

  1. Workflow Linting: Validate DAGs and schema mappings locally before deployment.
    hermodctl workflow lint path/to/workflow.json
    
  2. Secret Management: Manage enterprise secrets directly from the CLI.
    hermodctl secret set vault my-secret-key "my-value"
    
  3. Real-time Monitoring: Monitor worker health and cluster throughput in the terminal.
    hermodctl monitor
    
  4. GitOps Support: Export and import workflows as code for CI/CD pipelines.
    hermodctl workflow export --all > workflows.json
    
As a Library

Compiled as Example in pkg/engine/example_test.go, so it cannot drift from the packages it names.

Note that go get cannot fetch these packages at the current version — see go get does not work. Building against a checkout, or a replace directive pointing at one, does work.

import (
    "context"
    "log"
    "time"

    "github.com/gsoultan/hermod"
    "github.com/gsoultan/hermod/pkg/comm/buffer"
    "github.com/gsoultan/hermod/pkg/comm/formatter/json"
    "github.com/gsoultan/hermod/pkg/comm/sink/stdout"
    "github.com/gsoultan/hermod/pkg/engine"
    "github.com/gsoultan/hermod/pkg/engine/config"
)

func main() {
    source := mySource{} // anything implementing hermod.Source
    sinks := []hermod.Sink{stdout.NewStdoutSink(json.NewJSONFormatter())}
    buf := buffer.NewRingBuffer(1024)

    eng := engine.NewEngine(source, sinks, buf)
    eng.SetConfig(config.Config{
        MaxRetries:    5,
        RetryInterval: 200 * time.Millisecond,
    })

    if err := eng.Start(context.Background()); err != nil {
        log.Fatal(err)
    }
}

Start blocks until the context is cancelled or the source is drained.

Development Quick Start

One command brings up the whole stack — Postgres, the API + worker, and the UI dev server — and completes Hermod's first-run wizard for you, so all that is left is logging in:

./scripts/dev.sh

Then open http://localhost:5175 and sign in with admin / admin.

Option Effect
./scripts/dev.sh Start everything (PostgreSQL via Apple container)
./scripts/dev.sh --sqlite Use SQLite instead — no container needed
./scripts/dev.sh --reset Wipe the dev database and re-seed from scratch
./scripts/dev.sh --build-ui Refresh the UI bundle the API serves, then start
./scripts/dev.sh --stop Stop a running stack

Work against port 5175. It runs Vite, so edits under ui/src appear in the browser immediately via hot reload — no restart needed. Port 4005 also serves a UI, but that is the pre-built bundle in internal/api/static; it will not show your edits until you run --build-ui. It exists for checking what production actually ships.

Ports are chosen, not assumed. The stack prefers 4005 (API), 50051 (gRPC) and 5175 (UI), and steps up to the next free number for any of them already taken — so a second stack, or an unrelated service, never produces address already in use. The banner prints the numbers it settled on, and ./scripts/dev.sh --print-ports shows them without starting anything. Note that 50051 is the conventional gRPC port and therefore the likeliest to collide, even when both documented ports are free; a failed bind on it takes the whole backend down.

Notes:

  • PostgreSQL is created for you. On first run the script builds a postgres-dev container with Apple's container CLI (macOS 26+), configured with wal_level=logical for CDC, and creates the hermod_metadata, hermod_test_source and hermod_test_sink databases. Nothing to set up by hand.
  • Re-running is safe: an existing container is started rather than rebuilt, its data survives container stop, and an existing admin user is kept rather than recreated.
  • Your real configuration is never touched. The dev stack writes to .dev/ inside the repo via HERMOD_CONFIG_DIR, not ~/.hermod, so it cannot overwrite another Hermod instance's setup.
  • Logs stream to the terminal and are kept in .dev/logs/{backend,ui}.log.
  • Overridable: HERMOD_DEV_ADMIN_USER, HERMOD_DEV_ADMIN_PASS, HERMOD_DEV_API_PORT, HERMOD_DEV_GRPC_PORT, HERMOD_DEV_UI_PORT, HERMOD_DEV_PG_CONTAINER, HERMOD_DEV_PG_PORT. A port set explicitly is used as given — if it is busy the run fails and names the holder, rather than silently moving to another port you did not ask for.
Managing the database container
./scripts/create-postgres.sh              # create it (no-op if it already exists)
./scripts/create-postgres.sh --recreate   # destroy and rebuild from scratch
container exec -it postgres-dev psql -U postgres
container stop postgres-dev               # data is preserved

Overrides: HERMOD_DEV_PG_IMAGE (default postgres:18-alpine), HERMOD_DEV_PG_PASSWORD.

Data lives in the container's own filesystem — it survives stop/start but not container delete. PGDATA is deliberately not bind-mounted, because PostgreSQL's strict ownership requirements do not survive the macOS→Linux filesystem boundary reliably.

As an Application

Hermod can be run as a standalone application. By default, it starts in API Mode, which includes a web-based management platform for configuring sources, sinks, and engines.

  1. Run the application:

    go run ./cmd/hermod
    

    This will automatically build the UI (if not already built) and start the Go backend. The UI assets are served from the binary (or disk in dev mode).

    If you want to force a UI rebuild:

    go run ./cmd/hermod --build-ui
    

The UI will be available at http://localhost:4000.

Multi-Platform Support

Hermod is compiled for high performance and supports the following platforms and architectures:

  • OS: Linux only (Ubuntu, Debian, RedHat, Alpine).
  • Architecture: amd64, arm64.

You can download the latest binaries and packages (.deb, .rpm, .apk) from the GitHub Releases page.

API Mode (Default)

To start Hermod in API mode (which also serves the UI):

go run ./cmd/hermod

The UI will be available at http://localhost:4000.

You can customize the port and database for storing state:

go run ./cmd/hermod --port=8080 --db-type=sqlite --db-conn=~/.hermod/hermod.db

Hermod supports multiple databases for storing its state (Sources, Sinks, Workflows):

  • SQLite: --db-type=sqlite --db-conn=~/.hermod/hermod.db
  • PostgreSQL: --db-type=postgres --db-conn="postgres://user:pass@localhost:5432/hermod?sslmode=disable"
  • MySQL/MariaDB: --db-type=mysql --db-conn="user:pass@tcp(localhost:3306)/hermod"

When running in API mode, Hermod saves its database configuration to ~/.hermod/db_config.yaml after the first successful setup or when updated via the UI. Subsequent starts will automatically use this configuration.

Standalone Mode

In Standalone mode, both the API/UI and a worker are started in the same process:

go run ./cmd/hermod --mode=standalone
Worker Scaling and Sharding

Hermod supports horizontal scaling of workers. You can run multiple worker processes that share the same platform and automatically shard connections between them.

To start a worker-only process connected to the platform:

go run ./cmd/hermod --mode=worker --platform-url="http://localhost:4000" --worker-id=0 --total-workers=2
  • --mode=worker: Runs only the engine worker (no API/UI).
  • --platform-url: The URL of the Hermod platform API.
  • --worker-id: The unique ID of this worker (starting from 0).
  • --total-workers: The total number of workers in the cluster.

Workflows are automatically assigned to workers based on a hash of their ID. If the number of workers changes, the workflows will be re-sharded across the available workers.

Explicit Worker Assignment

You can also register workers in the Hermod platform and explicitly assign Sources, Sinks, and Workflows to a specific worker. This is useful when workers are running on different servers or in different vhosts.

  1. Register a worker via the API or UI. Each worker should have a unique GUID.
  2. Start the worker process with the --worker-guid and --platform-url flags:
    go run ./cmd/hermod --mode=worker --worker-guid="my-server-1" --platform-url="http://localhost:4000"
    
Worker Self-Registration

Instead of manually registering a worker in the UI, you can let the worker register itself upon its first run by providing additional flags:

go run ./cmd/hermod --mode=worker --worker-guid="my-server-1" --platform-url="http://localhost:4000" --worker-host="192.168.1.10"
  • --worker-host: The hostname or IP address where the worker is running.
  • --worker-port: The port the worker is using.
  • --worker-description: Optional description of the worker.

If a worker with the provided --worker-guid is not found in the database, it will be automatically created using the provided information. Name will default to the GUID.

  1. When creating or updating a Source, Sink, or Workflow, specify the worker_id to pin it to that worker.

If a component has a worker_id assigned, only the worker with the matching --worker-guid will process it. If no worker_id is assigned, the component is subject to the default hash-based sharding.

Worker Registration & Tokens (Security)
  • When you create a worker via the API/UI, Hermod generates a secret token for that worker.
  • For security, the worker token is returned only in the Create response. Subsequent GET/LIST responses do not include it.
  • Store the token securely and pass it to the worker process using the --worker-token flag (or environment variable).

Example single‑line command (shown in the UI after creation):

hermod --mode=worker --platform-url="http://localhost:4000" --worker-guid="<GUID>" --worker-token="<TOKEN>"

If you prefer, the worker can self‑register with --worker-host/--worker-port, but you still need to provide the --worker-token obtained at creation time for authenticated API calls.

Worker CLI via Environment Variables

To simplify production deployments (containers, systemd), Hermod supports environment variables as fallbacks when flags are not provided (or left at defaults):

  • HERMOD_MODE--mode
  • HERMOD_PLATFORM_URL--platform-url
  • HERMOD_WORKER_GUID--worker-guid
  • HERMOD_WORKER_TOKEN--worker-token
  • HERMOD_WORKER_HOST--worker-host
  • HERMOD_WORKER_PORT--worker-port
  • HERMOD_WORKER_DESCRIPTION--worker-description
  • HERMOD_WORKER_ID--worker-id
  • HERMOD_TOTAL_WORKERS--total-workers
Stall Detection & Recovery Tuning

These control when a workflow is declared wedged and rebuilt. The defaults suit most pipelines; a malformed or non-positive value is ignored in favour of the default, so a typo in a deployment variable can never switch a safety mechanism off.

Variable Default What it does
HERMOD_STALL_THRESHOLD 60s How long a pipeline may hold outstanding work without completing any of it before it is treated as wedged. Raise it for a workflow whose sink is legitimately slow; lower it to detect faster during an incident.
HERMOD_STREAM_SILENCE_INTERVAL 10s How often a CDC source's stream is sampled for silence. The deadline itself is not configurable — it is derived from the source server's own wal_sender_timeout, because that is what sets the keepalive cadence a healthy stream is held to.
HERMOD_LAG_WARN_BYTES 256MB How much un-acknowledged WAL a source may retain before it is reported. This guards the source database's disk.

Runbook — what the recovery log lines mean:

  • Pipeline stalled: work is outstanding but nothing has completed — the pipeline is holding work it has stopped finishing. Usually a sink. The workflow is being rebuilt; no data is lost, because un-acknowledged changes replay from the replication slot.
  • Source stream has gone silent: not even a keepalive has arrived — the replication connection is open and the slot still reports it attached, but the server has stopped serving it. Look at the source database, not the sinks.
  • Workflow stalled again while the last automatic restart was still settling — informational. The previous rebuild is being given its chance; backoff widens with each attempt.
  • Workflow stalled but automatic recovery is exhausted; manual intervention required — the restart budget for the window is spent. Fix the underlying fault, then stop and start the workflow: an operator stop is what restores its supervision budget.
  • Workflow logs could not be written to storage and were dropped — the log store is failing, not the pipeline. The lines themselves are still in the process log.

Example using env vars:

export HERMOD_MODE=worker
export HERMOD_PLATFORM_URL=http://localhost:4000
export HERMOD_WORKER_GUID=my-server-1
export HERMOD_WORKER_TOKEN=secret-token
hermod
Background OS Service Integration

Hermod can be integrated with the OS-level service manager (systemd on Linux) directly from the binary. This ensures that Hermod starts automatically on boot and can be managed using standard system tools.

Service commands:

  • hermod --service install - Install the service (requires administrative privileges).
  • hermod --service uninstall - Remove the service.
  • hermod --service start - Start the service.
  • hermod --service stop - Stop the service.
  • hermod --service restart - Restart the service.
  • hermod --service status - Check the current service status.

Examples: Install as a service

# Linux (systemd) — Worker mode
./hermod --mode=worker --platform-url="http://localhost:4000" --worker-guid="worker-1" --service install
./hermod --service start

# Linux — Standalone (API + Worker in one process)
./hermod --mode=standalone --service install
./hermod --service start

Or use the helper script provided in scripts/:

# Linux
bash scripts/install-service.sh standalone

The service will be configured to run with all the flags provided during the install command (except for --service itself`).

Production Considerations

  • Logging: Hermod uses a Logger interface and provides a default implementation using zerolog for zero-allocation structured logging. You can provide your own implementation via eng.SetLogger(myLogger).
  • Retries: The Engine automatically retries failed Sink.Write operations. Configure this via eng.SetConfig.
  • Health Checks: Sources and Sinks implement a Ping method. The Engine performs pre-flight checks using Ping before starting.
  • Startup Resilience: Hermod is designed to be resilient to initial infrastructure failures. If the primary storage backend is unavailable at startup, the process will continue to run and retry the connection periodically until successful, enabling automatic recovery in orchestrated environments.
  • Persistence: For production use cases requiring absolute durability, use the file_buffer option. This ensures that even if the process crashes, messages read from the source but not yet written to the sink are persisted on disk.
  • Graceful Shutdown: The Engine.Start method respects the provided context.Context. When the context is cancelled, the engine will stop reading from the source, signal the buffer to close, and wait for all pending messages in the buffer to be written to the sink before exiting. This ensures no data loss during normal shutdown procedures.
SQLite busy/locked handling

When using SQLite for the platform database, concurrent writes can occasionally hit SQLITE_BUSY ("database is locked"). Hermod mitigates this in two ways:

  • API returns HTTP 503 with Retry-After: 1 for busy errors on sink create/update. Clients should retry the request.
  • The storage layer automatically retries transient busy errors with bounded exponential backoff and respects request context deadlines.

You can tune SQLite's busy timeout via an environment variable (milliseconds):

HERMOD_SQLITE_BUSY_TIMEOUT_MS=15000
Advanced Logic & Control Flow

Hermod provides a suite of advanced nodes for complex data orchestration:

  • Switch Node: Branching logic based on message content. Supports multiple cases and a default path.
  • Foreach Node (Fan-out): Splits a single message into multiple independent messages based on an array field. Adds _fanout_group, _fanout_index, and _fanout_total metadata.
  • Collect Node (Fan-in): Synchronizes parallel branches from a Foreach node. Waits for all items in a group to arrive before emitting a single merged message.
  • Deduplicate Node: High-speed, in-memory deduplication using rotating Bloom filters. Prevents processing of duplicate messages within a rolling window.
  • Wait Node: Pauses execution for a specified duration (e.g., 10s, 1h). Durations > 30s are automatically suspended to the database for reliability.
  • Approval Node (HITL): Halts the workflow and creates a manual approval request. Supports custom form definitions (JSON) that users fill out in the Approvals UI.
  • Log Node: Explicitly sends data or fields to the live logging system, helpful for debugging production workflows.
  • Error Branching: All nodes support an error output branch. If a node fails, the engine automatically routes the message along the error edge if configured.
Execution‑Level Fan‑out (Foreach Node)

Hermod supports an execution‑level Foreach node that splits a single message into multiple independent messages based on an array path in the message data.

  • Configure in UI: Add a node of type Foreach and set the required "Array Path" (e.g., items, data.rows).
  • Runtime semantics:
    • For each element in the array, the engine emits a new message downstream.
    • Each emitted message includes helper fields in data:
      • _item: the current array element
      • _index: the 0‑based index
    • And correlation metadata for observability/idempotency:
      • _fanout_group: original message ID
      • _fanout_index: current index (string)
      • _fanout_total: total number of items (string)
  • Error handling: If the node fails (e.g., path missing or not an array), the engine routes the message along edges labeled error from the Foreach node.

Notes:

  • This Foreach node is different from the legacy data‑level "foreach transformer" which materializes arrays inside a single message. The execution‑level node produces N downstream executions.

Default is 15000 ms. WAL mode and other safe pragmas are enabled by default.

Distributed transactions (transactional sink groups)

A transactional sink group writes to several sinks inside one distributed transaction: either every member applied the batch or none did, and that holds across a crash. It is built on pkg/engine/twopc (the coordinator and its durable log) and pkg/comm/sink/txgroup (the group, which presents to the engine as a single sink).

Currently only the PostgreSQL sink can participate. It is the one sink implementing hermod.TwoPhaseCommit with real PREPARE TRANSACTION semantics, so the realistic shape today is two or more Postgres sinks kept consistent with each other. A group containing a sink that cannot participate is refused at construction, not silently degraded.

Declaring one

A group is a sink of type txgroup whose config names its members. It is a single node in the DAG, which is what lets the engine drive it through one writer — every sink otherwise gets its own writer goroutine with its own batching loop, and independent batches cannot share a transaction boundary.

{
  "type": "txgroup",
  "config": {
    "members": "orders-primary,orders-replica",
    "max_prepared_age": "15m"
  }
}
Field Meaning
members Comma-separated sink IDs, at least two. Each must implement two-phase commit, or the group is refused at construction.
max_prepared_age Optional (default 15m). How long a transaction may sit in doubt before the reaper rolls it back.

A durable state store is required. The coordinator's log has to outlive the process — a crash with an in-memory log strands prepared transactions with nothing able to resolve them — so a group refuses to start unless distributed state (Redis or Etcd) is configured.

On start-up the group preflights every member, resolves anything the previous run left in doubt, and starts its reaper. A member that cannot genuinely participate stops the workflow starting rather than failing mid-batch.

Why it is opt-in

The engine gives every sink its own writer goroutine with its own batching loop. That independence is why it sustains ~100k msgs/s, and it is also why sinks cannot otherwise agree on a transaction boundary. A group replaces that independence with a barrier: its members commit in lockstep at the pace of the slowest, and a member that cannot prepare aborts the batch for all of them. Use one where a partial write is genuinely worse than a slower pipeline; leave everything else on the fast path.

⚠️ Operational hazard — read before enabling on PostgreSQL

PREPARE TRANSACTION leaves a transaction in doubt: it holds its locks, keeps its snapshot, and survives a server restart until somebody resolves it. An unresolved prepared transaction therefore blocks VACUUM cluster-wide, which ends in transaction ID wraparound if it is left long enough. This is a database-availability problem, not an untidy row.

Hermod defends against that in three places, and you should understand all three before turning it on:

Defence What it does
Preflight At start-up, refuses to run if max_prepared_transactions = 0 (the PostgreSQL default, which makes PREPARE fail) or if the sink is behind a transaction pooler (where a prepared transaction cannot be resolved on the backend that created it).
Recovery On every start, resolves transactions left in doubt by the previous run. Presumed abort: a transaction commits only if the coordinator's decision was durable before the crash.
Reaper Rolls back anything prepared for longer than MaxPreparedAge (default 15 minutes). Run it continuously with Sink.StartReaper(ctx, interval)Recover only covers restarts, and the case that hurts is a coordinator that prepares, dies, and never comes back. It only ever rolls back: committing on a timer would apply a batch nobody decided to commit.

Required PostgreSQL configuration. max_prepared_transactions must be at least the number of concurrent transactional groups. It defaults to 0 and changing it requires a server restart:

max_prepared_transactions = 16   # postgresql.conf, then restart

Do not put a pooler in front of a group member. PgBouncer in transaction or statement mode cannot guarantee that the commit lands on the backend that prepared it. Hermod's preflight refuses this outright rather than degrading, because the degraded behaviour — committing at prepare time — would leave the coordinator believing it could still roll back.

Checking for stranded transactions. Prepared transactions are visible in pg_prepared_xacts, and this is worth alerting on:

SELECT gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;

Anything older than a few minutes that Hermod is not actively working on should be resolved. If Hermod's own log was lost — the state store was wiped, say — it cannot resolve them and you must do it by hand:

ROLLBACK PREPARED '<gid>';

Roll back rather than commit unless you have positively established the transaction should have committed. Presumed abort is the safe direction for the same reason the coordinator uses it.

Verified against a real server

The coordinator's unit tests use fakes, which prove the protocol is implemented but say nothing about whether PostgreSQL behaves the way it assumes. The integration tests in pkg/comm/sink/txgroup close that gap, asserting against pg_prepared_xacts — the same view an operator would check — rather than trusting a sink's account of itself:

HERMOD_INTEGRATION=1 POSTGRES_DSN='postgres://...' go test ./pkg/comm/sink/txgroup/

They found two defects that every fake-based test had passed over:

  • PreflightTwoPhaseCommit read SHOW max_prepared_transactions, which returns text. Scanning it into an int failed on every call — and being a preflight, that would have blocked 2PC from ever starting.
  • The coordinator never called Begin, so a real sink refused to prepare with "no active transaction". The fakes accepted Prepare unconditionally, so the missing phase was invisible.

Both are fixed, and both are now covered by tests that would catch a regression.

Residual risk, stated plainly

This window is closed. It used to exist between a participant's Prepare returning and its identifier reaching the log: the participant named its own transaction, so a crash in between left a prepared transaction the coordinator could not name — and on PostgreSQL a prepared transaction holds its locks cluster-wide until somebody finds it by hand in pg_prepared_xacts.

hermod.TwoPhaseCommit.Prepare now takes the transaction ID as an argument. The coordinator generates it, makes it durable, and only then asks the participant to prepare under it. The failure is inverted rather than narrowed: a crash now leaves at worst a name recorded for a transaction that was never prepared, and rolling back an identifier that does not exist is a no-op. An orphan you can name is a cleanup; one you cannot is an outage.

TestTheTransactionIsNamedBeforeItCanExist holds the ordering by observing, from inside a participant, what the store already contained at the moment it was asked to prepare. Reverting the order makes it fail and prints the identifier that would have been orphaned.

A participant may still report a different identifier than the one supplied — the PostgreSQL sink does when it is behind a transaction pooler and degrades to a local commit — and the coordinator records what it is told, because that is what recovery must act on. The pg_prepared_xacts check remains as a backstop for transactions this coordinator never created.

Connector maturity tiers

Hermod ships 41 source and 45 sink connectors. They are not equally mature, and a list that presents a 2,500-line integration-tested Postgres CDC reader next to an 87-line HTTP poller as equals is not telling you anything useful.

Tiers are assigned on evidence, not intent:

Tier Criteria
GA Substantial implementation, unit tests, and an integration test that runs against live infrastructure. Suitable for production.
Beta Substantial implementation with unit tests, but no live-infrastructure test. Expect to validate against your own environment first.
Experimental Thin implementation, no tests, or a known semantic limitation. Suitable for prototyping. Do not put data you cannot lose behind one.

Every connector is covered by the contract suite in pkg/comm/conformance, which checks lifecycle, nil-safety and context-deadline behaviour with no live infrastructure. That suite proves a connector is not obviously broken; it does not prove the data path, which is what separates GA from Beta.

The one exception is the txgroup sink itself, a composite that wraps other sinks. It has its own suite and its own PostgreSQL integration tests, which cover more than the generic contract could.

Connectors needing a collaborator — batchsql a DBProvider, form a submission store, grpc a .proto on disk — are constructed against a stub. That is deliberate: what is being checked is the connector's own lifecycle and context handling, and the stub is only the seam that lets it be built. Excluding them left six connectors with no coverage at all in order to avoid a theoretical weakness in coverage they did not have.

Connectors that address a fixed vendor host — Slack, Discord, Twitter/X, LinkedIn, Facebook, Instagram, TikTok, Pinecone — expose SetBaseURL so they can be pointed at a test server. Without that seam they dialled the live internet on construction, which is why they were untested; anything new in that shape should provide it too.

GA
Connector Direction Evidence
PostgreSQL source + sink Logical-replication CDC, 2,572 / 1,448 lines, 13 test files, live-DB integration + bulk-load + idempotency + PgBouncer e2e
MySQL source + sink Live-DB integration tests, idempotency coverage (MYSQL_DSN)
SQLite source + sink Local-file engine, tested in-process
File source + sink 1,238-line source with tests; used throughout the e2e suite
RabbitMQ source + sink Queue integration tests against a live broker, both directions: the sink's messages are consumed back off the queue
Redis sink Integration test against a live server
MongoDB sink Live-server data-path tests: documents land, a repeated key does not duplicate, updates replace, deletes remove, batches arrive whole
MongoDB source Live replica-set change-stream tests (MONGODB_RS_URI): the resume position advances on acknowledgement rather than on read, unacknowledged messages are redelivered after a restart, the initial load carries existing documents once, and a write during the backfill is not lost between it and the tail
SMTP sink Live send against a mail catcher, read back through its API (SMTP_HOST + SMTP_VERIFY_API) — an SMTP server accepts a message long before anyone can read it, so a nil return is a weaker claim than it looks. Retry and duplicate-suppression covered too
Kafka source + sink Live-broker round trip (KAFKA_BROKERS): a record written by the sink comes back out of the source with its key intact, and acknowledging advances the consumer group's offset so a restart is not handed what was already delivered. At-least-once only — see the note under Beta about why it cannot do better
ClickHouse sink Live-server tests (CLICKHOUSE_ADDR): an insert lands; a delete in a batch that also inserts does not come back, which it used to; mapped columns insert and delete; and a mapped column name cannot break out of its quoting
MSSQL sink Live-server tests against Azure SQL Edge (MSSQL_DSN): insert, upsert on redelivery and delete through the mapped path, and a column name that cannot be quoted is refused by name rather than becoming an empty identifier
Elasticsearch sink Live-server tests (ELASTICSEARCH_URL): a document is indexed and deleted, and a document id cannot inject its own actions into the bulk stream
pgvector sink Live-server tests (PGVECTOR_DSN): a vector is stored, upserted and deleted, and an identifier needing quotes gets them
S3 / S3-Parquet sink Live MinIO tests (S3_ENDPOINT): an object is put, distinct messages land separately, the default key keeps every delivery while the idempotent key does not leave a second object, a batch becomes a Parquet object, and one undecodable message is named rather than wedging the batch
Oracle sink Live-server tests against gvenzl/oracle-free (ORACLE_DSN, local rather than CI — the image wants ~2GB and a slow first boot): insert, upsert on redelivery and delete through the mapped path; a write into an existing, conventionally-named table; and the identifier guard asserted against the server, where an injected table name is refused and no table of that shape appears. Standing the server up is what found that the sink could not write to an ordinary Oracle table at all — identifiers were quoted PostgreSQL-style, but Oracle folds unquoted names to UPPER case, so every statement failed with ORA-00904
Cassandra sink Live-node tests (CASSANDRA_HOSTS): a row lands and a delete removes it, and a table name arriving on a message is refused rather than interpolated into CQL. The Cassandra source is a different matter — see Experimental
MQTT source Live-broker tests (MQTT_BROKER): a published message comes out of Read with payload and topic intact, and a 200-message burst arrives whole — the silent drop-oldest this source once had would have eaten its head
Beta

Substantial and unit-tested, but unproven against live infrastructure in CI:

Sources — MSSQL, gRPC, WebSocket, HTTP, BatchSQL, Excel. Sinks — Snowflake, HTTP, WebSocket, Failover.

The MSSQL source is genuinely a CDC source — it reads CHANGETABLE and emits updates and deletes — so what it lacks is coverage, not capability. The polling sources that were previously listed here are a different case and have moved to Experimental: see the inserts-only row below.

Snowflake carries one caveat the others do not. Its identifiers are validated like every other SQL sink's, but no warehouse is reachable from CI, so that guard is the only one in this repository never watched failing against a real server. It has tests that run without one — they cover the refusal rather than the resulting SQL. Oracle used to sit here too; standing a real server up moved it to GA and found a total-failure bug on the way (see its row above).

Kafka is GA for its data path but at-least-once only, and that ceiling is not a coverage gap. There is no transactional producer — segmentio/kafka-go exposes the wire primitives but nothing on its Writer — and Kafka cannot join a transactional sink group at all, because recovery there must be able to commit a transaction an earlier process prepared and Kafka can only abort one. See ROADMAP. At-least-once with sink-side idempotency is Hermod's documented guarantee everywhere, so this is the same bargain as the rest of the platform rather than a Kafka-specific caveat.

TxGroup stays here despite having live-PostgreSQL tests that cover more than most GA entries — commit landing in both tables, and rows staying invisible after a crash between prepare and recovery. Two things hold it back, and both are semantic rather than a gap in coverage: PostgreSQL is the only sink implementing hermod.TwoPhaseCommit, so a group can span nothing else; and there is a documented window in which a crash strands a prepared transaction the coordinator cannot name, which on PostgreSQL holds locks cluster-wide until someone finds it. See Residual risk, stated plainly. Neither is a reason to avoid it — they are reasons to know what you are taking on, which is what a tier is for.

Experimental

Thin, untested, or semantically limited. Specific caveats where they matter:

Connector Caveat
Oracle, DB2, ClickHouse, MariaDB, YugabyteDB (sources) Watermark polling, not log-based CDC. Inserts only — updates and deletes are invisible. The limitation is structural rather than a gap: these sources can only construct OpCreate and OpSnapshot, so there is no code path by which an update or a delete could reach a sink. A row changed after it was read is never re-emitted, and a deleted row is never retracted downstream. MariaDB is the easiest to be caught by: it has a binlog and Hermod's MySQL source does read one, so the MariaDB source looks like it should do CDC too. It does not. Use the MySQL source against MariaDB if you need updates and deletes. ClickHouse is listed here despite having live-infrastructure tests, because the tier is set by semantics rather than by coverage.
Cassandra, ScyllaDB (sources) Inserts only, as in the row above — updates and deletes are invisible. On top of that, CQL cannot ORDER BY an arbitrary column, so incremental polling returns an arbitrary qualifying row and the cursor can skip rows permanently. Sound only when the id field is a clustering column inside a restricted partition. The source logs a warning on first use. The Cassandra sink is unaffected and is GA.
SAP OData polling client (~180 lines). No IDoc, BAPI or delta queues. OData is SAP's sanctioned direction for third parties after Note 3255746, but it is roughly 10× slower than ODP-RFC for bulk extraction.
Mainframe, Dynamics 365, ServiceNow, Salesforce Thin REST/OData clients, no tests.
Social / SaaS — Slack, Discord, Telegram, Twitter/X, LinkedIn, Facebook, Instagram, TikTok, Google Sheets, Google Analytics, Firebase, FCM Small API wrappers. Contract-tested, but no data-path coverage. Fine for notifications; not for data of record. Their resume cursors advance on read, not on acknowledgement — Twitter/X, LinkedIn, Facebook, Instagram and TikTok still carry the defect fixed in the thirteen database and OData sources: a crash between reading and the sinks writing loses those items from the resume, because the persisted cursor is already past them. They are left as-is deliberately rather than changed mechanically: each advances a vendor pagination token (since_id, since, an opaque cursor) whose semantics differ per API, and no test here can exercise them. Treat a restart as potentially lossy for these. Dynamics 365 and SAP were in this list and are now fixed — both compare a configured ID field with OData's gt, which is a watermark rather than a vendor token, so the same fix applied.
Pinecone, Milvus, Kinesis, Pub/Sub, Pulsar, FTP, generic CDC, GraphQL, cron, webhook, form, batchsql, gRPC Minimal implementations. Contract-tested; no data-path coverage.

GA describes the data path — that changes are captured and land correctly. It does not mean a connector carries existing rows across when a workflow starts: only the PostgreSQL source does, and only when asked. See Maturity, up front.

Moving a connector up a tier means adding the missing evidence, not editing this table. If you depend on one, an integration test is the most useful contribution you can make.

Enterprise Features

Hermod is built for scale and reliability, offering enterprise-grade features out of the box:

  • Two-Phase Commit (2PC): Atomic multi-sink delivery via a transactional sink group, with a durable coordinator log, presumed-abort crash recovery and a reaper for transactions left in doubt. Postgres participates; Kafka does not.
  • SSO & OIDC: Centralized authentication via Okta, Azure AD, and Auth0, with automatic RBAC mapping.
  • Vector Database Sinks: Optimized connectors for Pinecone, Milvus, and pgvector for AI-driven data pipelines.
  • Hermod CLI: Full lifecycle management (Lint, Secret, Monitor, GitOps) from the command line.
  • Global Schema Registry: Centralized management of data contracts with versioning and compatibility checks. Supports JSON Schema, Avro, and Protobuf.
  • WebAssembly (WASM) Transformations: Run custom business logic at near-native speed. WASM nodes allow you to use Go, Rust, or C++ for complex data processing within the Hermod engine.
  • Adaptive Throughput Control: The engine automatically monitors processing latency and throttles ingestion if downstream sinks are under pressure or if worker resources are constrained.
  • Transactional interfaces: Optional Transactional / TwoPhaseCommit interfaces a sink may implement to support atomic processing patterns. Currently implemented with real prepared-transaction semantics by the Postgres sink only, and not yet driven by a cross-sink coordinator.
  • Granular RBAC: Role-Based Access Control allowing you to restrict access to Admins (full access), Editors (workflow management), and Viewers (dashboards only).
  • Automated PII Discovery & Masking: Intelligent sensitive data detection during the transformation phase to ensure compliance with GDPR/HIPAA.
  • Distributed Trace Visualization: Trace any message's journey through the DAG visually, showing latency and data mutations at every node.
  • Global State Store: Support for distributed backends like Redis and Etcd for consistent stateful transformations across worker clusters.
  • AI-Native Transformations: Cognitive ETL nodes for sentiment analysis, entity extraction, and automated mapping using LLMs.
  • OTLP Native Export: Integrated support for OpenTelemetry traces and metrics to connect with enterprise observability tools like Datadog and Honeycomb.
  • Transactional Outbox: Guaranteed message delivery and consistency for SQL-based sinks.

Data Governance and Schema Validation

Hermod allows you to enforce data quality by validating incoming messages against a schema before they are processed or written to sinks.

Supported formats:

  • JSON Schema: Standard JSON schema validation.
  • Avro: Binary-friendly JSON-based schema.
  • Protobuf: Enforce structure using .proto definitions.

Configuration:

  1. Open the Workflow Panel (right sidebar) in the Editor.
  2. Go to the Settings tab.
  3. Scroll to Data Governance.
  4. Select a Schema Type and provide the Schema Definition.

Messages that fail validation are:

  • Logged as errors in the live workflow logs.
  • Automatically redirected to the Dead Letter Sink (if configured).
  • Dropped from the pipeline to prevent downstream corruption.

Audit Logging

Hermod includes a robust audit logging system that tracks all critical administrative actions.

Tracked actions include:

  • Workflow lifecycle (Create, Update, Delete, Start, Stop)
  • Source and Sink management
  • User authentication and role changes
  • Dead Letter Sink draining

Audit logs are stored in the primary database (SQL or MongoDB) and can be viewed by Administrators in the Audit Logs page in the dashboard.

Authentication & Account Security

Hermod hardens the login flow to defend against brute-force attacks and information disclosure:

  • Login Lockout: After 5 consecutive failed login attempts for a given username/IP combination, that combination is temporarily locked out for 15 minutes. Locked requests receive an HTTP 429 Too Many Requests response with a Retry-After header, and the event is recorded in the audit log. A successful login immediately clears the failure counter, and stale counters reset automatically after 15 minutes of inactivity.
  • Sanitized Database Errors: When Hermod cannot connect to its database, error messages returned to API/UI clients are sanitized to strip database host/IP addresses and ports (e.g. dial tcp 10.0.0.5:5432: connection refused). This prevents leaking internal infrastructure details during setup, connectivity tests, and login. Internal database errors during login are surfaced only as a generic internal server error.

These protections apply automatically and require no configuration. The relevant thresholds are defined as constants (MaxLoginAttempts, LoginLockoutDuration) in internal/api/handlers/login_security.go.

Two-Factor Authentication (2FA)

Hermod supports TOTP-based Two-Factor Authentication (compatible with Google Authenticator, 1Password, Authy, etc.).

  • Enable 2FA (user self-service):

    1. Start setup to get a temporary secret and provisioning URL (QR):
      • POST /api/auth/2fa/setup (authenticated)
      • Response: { "secret": "...", "url": "otpauth://totp/..." }
    2. Scan the QR code (or enter the secret) in your authenticator app and generate a 6‑digit code.
    3. Confirm to enable 2FA permanently:
      • POST /api/auth/2fa/verify with body { "secret": "...", "code": "123456" }
    4. On success, the server stores the secret and marks the account as 2FA‑enabled.
  • Disable 2FA:

    • POST /api/auth/2fa/disable (authenticated)
  • Login flow with 2FA enabled:

    1. Submit username and password:
      • POST /api/login → returns { "two_factor_required": true, "user_id": "...", "pending_token": "..." } when 2FA is enabled.
    2. Complete login by submitting the 6‑digit TOTP code with the pending token:
      • POST /api/auth/2fa/login with { "user_id": "...", "pending_token": "...", "code": "123456" }
      • On success, the API returns a session token and sets the hermod_session cookie.
  • First-time enrollment during login (when 2FA is enabled but not registered yet):

    • If an administrator enabled 2FA for a user without completing registration, password login will respond with { "two_factor_enroll_required": true, "user_id": "...", "pending_token": "..." }.
    • Use the pending token to enroll without a session:
      1. Start enrollment and get a secret + provisioning URL (QR):
        • POST /api/auth/2fa/setup/pending with { "user_id": "...", "pending_token": "..." }
        • Response: { "secret": "...", "url": "otpauth://..." }
        • The login page renders the url as a scannable QR code (generated locally in the browser) so it can be added by scanning with any authenticator app — Google Authenticator, Authy, Microsoft Authenticator, Apple/iOS Passwords, etc. The plaintext secret is also shown as a manual-entry fallback.
      2. Verify and complete enrollment (also completes login):
        • POST /api/auth/2fa/verify/pending with { "user_id": "...", "pending_token": "...", "secret": "...", "code": "123456" }
        • On success, the server persists the secret, issues a session token, and sets the hermod_session cookie.

Notes:

  • 2FA secrets are never returned after verification; responses and cookies omit the secret.
  • Audit logs record 2FA enable/disable and successful 2FA logins.
  • The HERMOD_JWT_SECRET must be set (or present in ~/.hermod/db_config.yaml) for token issuance.
  • The pre-auth endpoints /api/auth/2fa/login, /api/auth/2fa/setup/pending, and /api/auth/2fa/verify/pending do not require a session cookie: they are authenticated solely by the short-lived signed pending_token issued by /api/login. They are intentionally exempt from the session-auth middleware so the OTP-challenge and first-time enrollment steps can complete before a session exists. The UI also avoids redirecting to /login on a 401 from these endpoints (e.g. a wrong code), so users can retry without being bounced out.

Reliability and Data Loss Prevention

Hermod is designed to minimize data loss during operation and shutdown:

  • Graceful Draining: During shutdown, Hermod drains its internal buffer to ensure all messages already read from the source reach the sink.
  • At-Least-Once Delivery: The engine acknowledges messages to the source only after they have been successfully written to the sink. This ensures that if the process crashes, the source can re-deliver unacknowledged messages upon restart (depending on source implementation).
  • Retries with Backoff: Failed writes to the sink are automatically retried with configurable exponential backoff.
  • Circuit Breaker Pattern: Prevents cascading failures when downstream systems are unhealthy. Automatically opens after N consecutive failures and probes recovery in a half-open state.
  • Adaptive Batching: Dynamically groups messages to optimize sink throughput and reduce network roundtrips.
  • Memory Safety: Uses a bounded RingBuffer to prevent out-of-memory issues under high pressure.

Important Note: Since the default RingBuffer is in-memory, sudden process termination (e.g., SIGKILL or power failure) can result in the loss of messages currently held in the buffer. For use cases requiring absolute durability, consider implementing a persistent Producer/Consumer (buffer) interface (e.g., using a file-backed queue or a dedicated message broker).

Dead Letter Sink (DLQ) Prioritization

In high-reliability scenarios, some messages might fail to be written to the primary sink even after all retry attempts. Hermod can redirect these messages to a Dead Letter Sink.

If you want to ensure that historical failures are processed before new data (e.g., during recovery after a downstream outage), enable DLQ Prioritization:

  1. Configure a Dead Letter Sink: Assign a Sink (e.g., a Postgres table) to the workflow's dead_letter_sink_id.
  2. Enable Prioritize DLQ: Set prioritize_dlq: true in the workflow configuration.
  3. Automatic Recovery: When the workflow starts, Hermod will first attempt to "drain" all messages from the Dead Letter Sink before switching to the primary source stream.

Note: The Sink assigned as a DLQ must also implement the hermod.Source interface (e.g., Postgres, MySQL, NATS, Kafka).

A sample template for this configuration is available at examples/templates/reliability_recovery_dlq.json.

PostgreSQL CDC: Replication Slots & Publications

When using a PostgreSQL source in CDC mode, Hermod relies on a logical replication slot and a publication. The Source form now helps you reuse existing objects instead of guessing names:

  • Discovery: Opening the Slot Name or Publication dropdown queries the connected database (pg_replication_slots, pg_publication, pg_publication_tables) and lists what already exists. Publications that already cover your configured tables are highlighted.
  • Choose or Create: Pick an existing slot/publication from the list, or type a new name — Hermod will automatically create it on startup if it does not exist.
  • Defaults: If both fields are left blank, Hermod falls back to the safe defaults hermod_slot and hermod_pub.

This is backed by the API endpoint POST /api/sources/discover/replication, which accepts { "type": "postgres", "config": { ... } } and returns the available slots and publications.

Requirement: The database must have wal_level = logical and the connecting user must have replication privileges for slot creation to succeed.

Workflow Versioning & Rollback

Every time you save a workflow, Hermod automatically creates an immutable version in the database. This provides a complete audit trail and enables safe, rapid recovery:

  • Immutable History: View all previous versions of a workflow, including the author, timestamp, and a summary of changes.
  • One-Click Rollback: Instantly revert a production workflow to any previous stable version via the History tab in the Workflow Detail page.
  • GitOps Readiness: Versioning ensures that workflow configurations can be managed as code and safely promoted across environments.

Distributed State & Coordination

For large-scale, high-availability deployments, Hermod supports distributed backends for state management and worker coordination:

  • Global State Stores: Native support for Redis and Etcd to store workflow state (e.g., aggregation counters, windowed buffers). This ensures consistency when workflows migrate between workers.
  • Worker Leases: Distributed coordination ensures that each workflow is processed by exactly one worker instance at a time, preventing processing overlaps.
  • Hash-based Sharding: Automatically and transparently balances workflows across all available worker instances in a cluster.

Workflow Blueprints & Templates

Hermod provides a library of pre-built "Blueprints" to jumpstart common data integration patterns. These can be imported with a single click and customized to your needs.

Examples include:

  • CDC to Elasticsearch: Real-time synchronization of database changes to a search index.
  • API Aggregator: Consolidate data from multiple external APIs into a single stream.
  • GDPR Masking & Routing: Automatically redact PII and route high-value data to specialized sinks.

Browse the available templates in the examples/templates/ directory or directly via the Import Template button in the Workflow Dashboard.

Real-time Data Streams (SSE)

Hermod includes a built-in SSE (Server-Sent Events) Sink that allows you to stream data directly to web applications or any SSE-compatible client. This is ideal for real-time dashboards, live notifications, or reactive data orchestration without the complexity of a full message broker.

Security Features
  • Authentication: Secure your streams with an Auth Token. Clients must provide the token via the Authorization: Bearer <token> header or the token query parameter.
  • Origin Verification: Restrict access to specific web origins (CORS) to prevent unauthorized websites from subscribing to your data streams.
  • Interactive Integration Guide: The Hermod UI provides a real-time, tailored JavaScript snippet for each SSE Sink, including security parameters, to simplify client-side integration.
Consuming the Stream

Clients can subscribe to a specific stream using the following endpoint: GET /streams/sse?stream={stream_name}&token={optional_token}

The endpoint is separated from the management API to ensure data isolation and high throughput.

Sample Client

A complete HTML/JavaScript sample demonstrating how to consume an SSE stream is available in the examples/sse-sink/ directory.

To use it:

  1. Configure an SSE Sink in your workflow with a custom stream name and optional security settings.
  2. Open examples/sse-sink/index.html in your browser.
  3. Enter the stream name and connect to see live data.

Observability

Hermod provides built-in Prometheus metrics to monitor your data pipelines. Metrics are exposed via the /metrics endpoint on the API server.

Key Metrics:

  • hermod_engine_messages_processed_total: Total messages successfully processed.
  • hermod_engine_messages_filtered_total: Messages dropped by filters.
  • hermod_engine_message_errors_total: Processing errors categorized by stage (read, transform, sink).
  • hermod_engine_sink_writes_total: Successful writes per sink.
  • hermod_engine_sink_write_errors_total: Failed writes per sink.
  • hermod_engine_processing_duration_seconds: End-to-end processing latency.
  • hermod_engine_dead_letter_total: Messages sent to the Dead Letter Sink.

Workflow Metrics:

  • hermod_workflow_node_processed_total: Number of messages processed by a specific workflow node.
  • hermod_workflow_node_errors_total: Number of errors encountered in a specific workflow node.

Worker Metrics:

  • hermod_worker_sync_duration_seconds: Time taken for a worker synchronization cycle.
  • hermod_worker_active_workflows_total: Number of active workflows currently managed by the worker.
  • hermod_worker_sync_errors_total: Total number of worker synchronization errors or workflow start failures.
  • hermod_worker_admission_rejected_total: Workflows not started because the worker was over its CPU/memory admission threshold, labelled by reason (cpu or memory).
Tracing — following one record end to end

Traces are exported over OTLP when it is configured (see OTLPConfig); nothing is emitted otherwise. A record produces one trace:

source.receive          ← where it entered, one per message
└─ RunWorkflowNode      ← one per node it passes through
   └─ sink.write        ← one per sink write

The trace context travels on the message, not in a context.Context. The read loop and the sink writers are different goroutines joined by a buffer, so a Go context cannot reach from one to the other. It is carried in message metadata under the W3C traceparent key — which means a sink that forwards metadata as headers or attributes (Pub/Sub does) propagates the trace to whatever consumes it next, with no Hermod-specific handling required.

A record that arrives without a traceparent starts a new trace rather than failing. Tracing is diagnostics, and must never be the reason a record does not move.

Batch writes use links, not a parent. The messages in one batch were read separately and each carries its own trace, so sink.write_batch links to all of them. Promoting one to parent would claim the batch belonged to that record's trace and orphan every other one.

Alerting on silent failures

Each of these has a procedure in RUNBOOK.md, along with key rotation, backup and restore, replication-slot WAL retention, and what is known not to be covered.

Most pipeline problems announce themselves as errors. These four do not — they lose or withhold data while every status stays green — so each one needs an alert rather than a dashboard.

Metric What a non-zero value means Action
hermod_message_over_releases_total A message was released after its reference count already reached zero, so it returned to the pool while still in use and another message overwrote it. Symptom is duplicated and lost payloads with the totals still balancing. Page. This is a code defect, never a capacity issue. Capture the workflow topology and open a bug; the TestMain guards in internal/engine/registry and pkg/engine should have caught it before release.
hermod_engine_messages_dropped_no_target_total Messages were acknowledged to the source and then delivered nowhere, because a workflow that has sinks resolved none of them. They are not in a dead-letter queue. Page. Check sink reachability and the workflow's edges. Data already acknowledged is unrecoverable from the source.
hermod_worker_admission_rejected_total The worker is shedding load: it is refusing to start new workflows because the host is above its threshold. Affected workflows simply never start. Investigate host load. The reading is host-wide, so on a shared machine this can fire on load Hermod does not own — raise HERMOD_ADMISSION_CPU_THRESHOLD / HERMOD_ADMISSION_MEM_THRESHOLD, or give the worker a dedicated node.
hermod_engine_sub_source_backoff_total One source inside a multi-source workflow is failing and has been backed off. Its siblings keep streaming, so the workflow still reports healthy while that source delivers nothing. Check that source's connectivity. Sustained growth means it is not recovering; the backoff caps at 5s between attempts.
hermod_sink_unmapped_field_total A message carried a field the sink's column mappings do not cover, so it was not written. Usually the source grew a column: the destination has quietly stopped matching it, and every status stays green. Add the field to the sink's column mappings, or confirm the omission is intended. Labelled by table and field, and counted once per field per sink rather than per message — the value is which field, not how many rows.
hermod_txgroup_in_doubt A transactional sink group has prepared transactions it has not resolved. On PostgreSQL each one holds locks and blocks VACUUM cluster-wide — not just for that table, and not just for Hermod — for as long as it lasts. Page if it stays above zero. Brief non-zero values are normal mid-commit. Sustained ones mean the reaper is not resolving them; check the group's members are reachable and cross-check SELECT * FROM pg_prepared_xacts on the destination. Republished on every sweep, so it clears on its own once resolved.
hermod_txgroup_reaped_total The reaper rolled back transactions left in doubt past their deadline. The backstop working as designed — and evidence something upstream failed to resolve its own transaction. Not itself an emergency, but growth means commits are being abandoned rather than completed. Look for coordinator crashes or unreachable members around each increment.
Schema evolution — when a source grows a column

A CDC source picks up whatever the upstream table has, so adding a column there starts sending it without anything in Hermod being reconfigured. What happens next depends on how the sink is configured, and both behaviours are deliberate:

  • Without column mappings, a SQL sink writes (id, data) with the message as a JSON document, so the new field arrives on its own. Nothing to do.
  • With column mappings, the sink writes exactly the columns it was told about. The new field is not written — a mapping is a statement about which fields matter — but it is reported through hermod_sink_unmapped_field_total and a log line naming the field, so the loss is visible rather than silent.

Neither mode alters the destination's schema in response to a message. Automatic column addition exists only as sync_columns, which reconciles the table to the configured mappings at start-up, not to what arrives at runtime.

Admission control knobs (both default to 0.85; set to 1 or higher to disable that dimension):

HERMOD_ADMISSION_CPU_THRESHOLD=0.85   # refuse new workflows above this host CPU fraction
HERMOD_ADMISSION_MEM_THRESHOLD=0.85   # refuse new workflows above this host memory fraction
Rebalancing and failback

When workers compete for a workflow, the current owner gets a weight bonus so workflows do not flap between workers on small load differences — every move is a stop and a restart, which for a CDC source means tearing down a replication connection.

The trade-off is that rebalancing by load is slow. At the default 2.0, a saturated worker still retains roughly 199 keys in 200 against a completely idle peer, so a worker that has just recovered may reclaim very little for a long time. Failback after a worker dies is unaffected and complete — a dead owner stops being a candidate at all, and its workflows move immediately.

HERMOD_LEASE_HYSTERESIS=2.0   # incumbent's weight multiplier; 1.0 disables the bonus

Lower it towards 1.0 to trade stability for faster load-following. Measured on a two-worker cluster with one idle and one saturated worker, an idle worker reclaims 1 key in 200 at 2.0 and 192 in 200 at 1.0 (TestLeaseHysteresisIsConfigurable).

Delivery guarantee

Delivery is at-least-once: a message is acknowledged to its source only after every sink write succeeds, so a crash or an abrupt stop replays whatever was not acknowledged. Duplicates are therefore possible and expected.

What makes that exactly-once as observed at the destination is the sink's upsert. Every SQL sink writes with ON CONFLICT / ON DUPLICATE KEY / MERGE, keyed on the message id, so a redelivered message overwrites its own row rather than adding one.

That holds on one condition: the source must supply a stable identity. The key is taken from, in order:

  1. metadata["idempotency_key"] — set this when the source knows its own natural key (a CDC row's primary key, an order number);
  2. the message id;
  3. a generated UUID, if neither is present.

A message that reaches case 3 gets a different key on every delivery, so its duplicates cannot be collapsed — two deliveries of an identity-less message are genuinely indistinguishable. If a source of yours produces messages without an id, set idempotency_key in a transformation before the sink.

Sinks that are not upserts (webhook, SMTP, queues) deduplicate only where they say so; SMTP computes its own key from the message and recipient.

S3 keeps every delivery by default. Its object key carries a timestamp, so a redelivery lands beside the delivery before it rather than on top of it. That is what an archive wants — successive CDC updates to one row share a message id, and keying on the id alone would keep only the newest — but it does mean a retry leaves a duplicate object, and retries are the mechanism at-least-once works by. Set idempotent_key: "true" on the sink to key on the message id instead, which gives the upsert behaviour the guarantee above describes, at the cost of keeping only the latest version of each record.

Deploying on Kubernetes

A container image and a Helm chart ship with each release:

helm install hermod oci://ghcr.io/gsoultan/charts/hermod \
  --version 1.0.0-rc.1 \
  --set existingSecret=hermod-master-key \
  --set metrics.prometheusRule.enabled=true

--version is not optional while the current release is a candidate: without it Helm reports could not locate a version matching provided version string.

Or from a checkout: helm install hermod deploy/helm/hermod.

The chart wires the pieces this document describes and that are easy to get subtly wrong: /livez as the liveness probe and /readyz as readiness (never the other way round — pointing liveness at /readyz restarts every pod during a database blip instead of merely routing away from them), a terminationGracePeriodSeconds that exceeds HERMOD_SHUTDOWN_TIMEOUT, the crypto master key as a Secret, and a volume for db_config.yaml.

It refuses to render four configurations that install cleanly and then lose data, rather than letting you find out during a rolling restart:

Refused Why
terminationGracePeriodSecondsshutdownTimeout Kubernetes kills the pod mid-drain; everything taken from a source and not yet written is discarded.
Both masterKey and existingSecret Ambiguous which key encrypts stored credentials.
replicaCount > 1 with no shared database Each replica keeps its own workflows, leases and users, so they cannot coordinate.
replicaCount > 1 with a ReadWriteOnce volume Replicas after the first stay unschedulable.

The image is distroless and runs as uid 65532 with a read-only root filesystem. Hermod serves plain HTTP and has no TLS listener of its own; terminate TLS at the ingress.

/metrics is open by default, as a scrape target normally is — requiring a session cookie would break every scraper. The metrics do carry workflow_id, source_id and worker_id labels though, so an unauthenticated read maps the deployment: how many pipelines exist, what they are called, and which are failing. Set HERMOD_METRICS_TOKEN (or metrics.token in the chart, which wires the ServiceMonitor to match) to require Authorization: Bearer <token>. The health probes stay open either way — a token covering them would make the kubelet fail every probe and restart the pod on a loop. Either way, keep the Service ClusterIP rather than behind a public LoadBalancer.

Enable metrics.prometheusRule.enabled to install the four alerts in Alerting on silent failures. Two of them page.

Credential encryption and master key rotation

Connector credentials — passwords, API keys, DSNs, GCP service-account documents — are encrypted with AES-256-GCM before they reach the metadata database, and decrypted on read. Which values count as credentials is decided by shape rather than by an exact list (internal/storage/configsecrets), so a connector added later with an smtp_password is covered without anyone remembering to add it. Non-credentials that merely look like one — s3_key is an object path, routing_key is routing — are listed explicitly as exceptions.

Set a master key. Without one, Hermod encrypts with a constant that is published in this repository, which protects nobody. Set crypto_master_key in db_config.yaml, or rotate through PUT /api/config/crypto (Admin only, minimum 16 characters).

Rotation re-encrypts before it switches. PUT /api/config/crypto rewrites every stored credential under the new key first, in one transaction, and installs the key only once that has committed. If anything cannot be re-encrypted the request fails and nothing is changed — including the case where a value is already unreadable from an earlier bad rotation, because re-encrypting it would mean writing back a blank and destroying a credential that the right key could still recover.

A value that cannot be decrypted reads back empty, never as raw ciphertext, and logs which key failed. Handing ciphertext to a driver as though it were the password is what previously turned a key mistake into unexplained authentication failures against the destination database.

Upgrades are safe: key derivation changed from truncate-and-zero-pad to SHA-256, and Decrypt falls back to the old derivation, so existing data still opens and moves forward as it is rewritten.

Backup and restore

GET /api/backup/export and POST /api/backup/import (both Admin only) carry sources, sinks, workflows, vhosts, workspaces and notification settings.

The export contains decrypted credentials in plaintext — necessarily, since a backup that cannot restore a credential is not a backup, and the target instance may have a different master key. Treat the file as a secret: it is every credential in the deployment in one document.

Both endpoints fail loudly rather than quietly. An export that cannot read the database returns an error instead of downloading an empty file with a plausible name, and refuses rather than truncating if the deployment holds more than 1000 objects of any one kind. A restore reports which objects could not be written instead of always answering 204; it still attempts every object, because recovering most of a configuration beats stopping at the first bad row.

Shutdown budget — must fit inside your orchestrator's grace period

On SIGTERM, Hermod stops accepting new work and then drains: it finishes writing the messages it has already taken from its sources and acknowledges them, so they are neither lost nor replayed. That takes time, and if the orchestrator kills the process partway through, the undelivered remainder is discarded — the drain protects nothing.

Every shutdown stage derives from one total, so the stages always nest:

Stage Share of total Default What it bounds
Total 100% 25s The whole stop, including closing storage
PerEngine 80% 20s Stopping one workflow, and StopAll across all of them
Drain 55% ~13s Sink writes once shutdown has begun
Grace 20% 5s Writers unwinding after the drain budget expires
HERMOD_SHUTDOWN_TIMEOUT=25s   # keep below terminationGracePeriodSeconds

The default is 25s because Kubernetes' default terminationGracePeriodSeconds is 30s. If you raise HERMOD_SHUTDOWN_TIMEOUT, raise the grace period to match — and leave a few seconds of margin, because the kubelet's timer starts before the signal is delivered and the process still has to close its database handles after the engines stop:

spec:
  terminationGracePeriodSeconds: 60   # must exceed HERMOD_SHUTDOWN_TIMEOUT
  containers:
    - name: hermod
      env:
        - name: HERMOD_SHUTDOWN_TIMEOUT
          value: "45s"

A per-sink drain_timeout larger than the budget is clamped to it rather than allowed to overrun the process-wide deadline. TestShutdownBudgetStagesNest and TestShutdownDefaultFitsKubernetesGracePeriod hold both properties.

Performance Tuning Guide

This section summarizes practical knobs to keep Hermod lightweight (low CPU/RAM) while maintaining throughput and reliability.

Engine flags and settings:

  • engine.max_inflight (default: 128)
    • Caps the number of in‑flight messages across the pipeline to bound memory.
    • Increase for faster sinks, decrease for small instances with tight RSS limits.
    • Must be ≥ your largest sink batch_size. A batch fills from in‑flight messages, so a batch_size above max_inflight can never complete on count and every flush waits out batch_timeout instead — measured at 2,557 msgs/s versus 110,829 msgs/s for the same workload. Hermod now clamps the effective batch size to max_inflight and logs a warning naming both values; raise max_inflight if you want the larger batch to take effect. See BENCHMARKS.md.
  • engine.drain_timeout (default: 10s)
    • Logs a warning if sink writers take longer than this to drain on shutdown. Set 0 to wait indefinitely.
  • prioritize_dlq (per‑workflow)
    • When enabled and a DLQ sink is present, Hermod drains DLQ first before consuming the primary source to avoid starvation of historical failures.

Sink batching and backpressure (per sink):

  • batch_size, batch_timeout, batch_bytes
    • Batch flush triggers on count OR bytes OR timeout — tune to balance latency and throughput.
    • Typical starters: batch_size: 100–128, batch_bytes: 1_048_576 (1MB), batch_timeout: 100–250ms.
    • Keep batch_sizeengine.max_inflight (default 128). If you want 200–500, raise max_inflight to match — otherwise the batch is clamped. Measured best throughput is at batch_size: 100 (124,877 msgs/s); see BENCHMARKS.md.
  • Backpressure buffer and strategy
    • backpressure_buffer: bounded channel size (e.g., 1000–5000)
    • backpressure_strategy: block | drop_oldest | drop_newest | sampling | spill_to_disk
    • Prefer block (default) unless you need lossy behavior under overload.

Ordered concurrency via sharding (per sink):

  • shard_count: number of internal worker shards per sink writer (e.g., 4–16)
  • shard_key_meta: metadata key used to shard (falls back to Message.ID())
    • Guarantees per‑key ordering while parallelizing independent keys.
High Availability and Failover

Hermod workers are designed for high availability. When a worker is gracefully shut down:

  • It releases all active workflow leases.
  • It deregisters itself from the cluster.
  • Other workers automatically detect the change and re-shard the workflows.
  • Workflow execution moves to the remaining workers "smoothly" with minimal interruption.
  • For optimal failover speed, you can configure the worker cache TTL.

Idempotency store hygiene (SMTP / SQLite helper):

  • enable_idempotency: true
  • idempotency_dsn: ~/.hermod/hermod.db
  • idempotency_namespace: <string> → isolates keys into a dedicated table (e.g., smtp_idempotency_marketing).
  • idempotency_ttl: 72h → hourly cleanup of stale keys keeps the store fast and small.

Database pooling defaults:

  • Non‑SQLite: MaxOpenConns=20, MaxIdleConns=10, ConnMaxIdleTime=60s
  • SQLite (embedded): MaxOpenConns=4, MaxIdleConns=1 (WAL mode recommended)

Logging and profiling:

  • HERMOD_LOG_SAMPLE_N=5 → sample warn/error logs (keep 1/n) to reduce noisy hotspots.
  • HERMOD_PPROF=true → enables /debug/pprof/* endpoints for on‑the‑fly CPU/heap profiling under load.

OpenTelemetry (OTEL):

  • The engine emits spans for sink.write and sink.write_batch with attributes workflow_id, sink_id, message_id/batch_size.
  • Configure OTEL exporter in your environment to collect traces.

Suggested starting targets:

  • Idle worker: < 80 MB RSS, ~0% CPU. (target — not yet benchmarked)
  • Fast sink (e.g., Kafka/NATS): 5–20k msgs/s with max_inflight=128, batch_size=100–128, p95 < 50ms.
  • Postgres sink: measured 58k–100k rows/s for insert-only batches of 1k–5k rows via the COPY fast path, versus 3–6k rows/s on the ordered per-message path. See BENCHMARKS.md.
Bulk-load fast path (Postgres)

Insert-only batches are streamed into a TEMP staging table with COPY and merged in a single INSERT … SELECT … ON CONFLICT, giving 10–16× the throughput of the per-message path.

This is automatic and requires no configuration, but it is deliberately conservative: a batch takes the fast path only when it is insert-only, targets a single table, has column mappings, uses no soft-delete strategy, and contains at least 50 rows. Any CDC batch that mixes inserts, updates or deletes stays on the ordered path, because the order of a delete followed by an insert on the same key is observable and must be preserved.

Benchmarks

Measured baselines live in BENCHMARKS.md, which also documents the host they were taken on and how to reproduce them. Headline numbers (Apple M5 Pro, in-memory source and sink, so this is engine overhead only — network and disk cost are excluded):

Benchmark Result
Engine throughput, 1 KB payload 118,285 msgs/s
Engine throughput, 64 B payload 101,249 msgs/s
Engine throughput, 16 KB payload 45,456 msgs/s
Message pool AcquireRelease 76.67 ns/op, 0 allocs/op
Unpooled equivalent 184.6 ns/op, 7 allocs/op

The engine core sustains ~100k msgs/s, so it is not the bottleneck in a Hermod pipeline — sinks are. Direct tuning effort at the sink.

Numbers not yet backed by a benchmark are marked (target) above and listed under "Not yet measured" in BENCHMARKS.md.

Health and Readiness Probes

Hermod exposes production-friendly health endpoints on the API server:

  • GET /livez — liveness probe. Always 200 once the server is up.
  • GET /readyz — readiness probe (v1 schema). Performs bounded checks and returns JSON with per-component status and durations. Only database connectivity failures are gating (HTTP 503). Registry and Workers checks are informational (non-gating) in v1.

Example response:

{
  "version": "v1",
  "status": "ok",
  "time": "2026-01-22T20:00:00Z",
  "checks": {
    "db": { "ok": true, "duration_ms": 3 },
    "registry": { "ok": true, "engines_running": 2, "duration_ms": 1 },
    "workers": { "ok": true, "recent": 2, "stale": 0, "ttl_seconds": 60, "duration_ms": 2 }
  }
}

Prometheus metrics:

  • hermod_readiness_status{component="db|registry|workers"} (gauge: 1=ok, 0=error)
  • hermod_readiness_latency_seconds{component="..."} (histogram)

Kubernetes probes (example):

readinessProbe:
  httpGet:
    path: /readyz
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 3
livenessProbe:
  httpGet:
    path: /livez
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

Note: In v1, only the DB check gates readiness. Future versions will incorporate workflow ownership/leases into readiness once lease-based coordination is enabled.

Idempotency and Exactly-Once Effects (Sink-Side)

Hermod processes messages with at-least-once delivery. To avoid duplicates at sinks, idempotency is implemented end-to-end:

  • Engine ensures each message carries a stable idempotency key (defaults to message ID). Metrics are emitted for present/missing keys.
  • SQL sinks (Postgres/MySQL/MariaDB) perform UPSERT semantics on the id primary key:
    • Postgres/Yugabyte: INSERT ... ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data
    • MySQL/MariaDB: INSERT ... ON DUPLICATE KEY UPDATE data = VALUES(data)
  • Elasticsearch sink performs UPSERT by using the message id as the document _id.
  • SQLite sink uses INSERT OR REPLACE into a table with id TEXT PRIMARY KEY.
  • Redis sink deduplicates with SETNX using a configurable TTL and namespace; duplicates are skipped.

Environment variables:

  • HERMOD_IDEMPOTENCY_REQUIRED=true — log warnings when idempotency keys are missing.
  • HERMOD_IDEMPOTENCY_TTL=24h — TTL for Redis dedupe keys.
  • HERMOD_IDEMPOTENCY_NAMESPACE=hermod:idemp — prefix for Redis keys.

Modern API Sources (GraphQL & gRPC)

Hermod supports modern API protocols as data sources, allowing you to push data directly into Hermod instead of relying solely on CDC or webhooks.

GraphQL Source

Hermod exposes a GraphQL endpoint at /api/graphql/{path}. You can send standard GraphQL POST requests:

{
  "query": "mutation { publish(table: \"orders\", payload: \"...\") }",
  "variables": {}
}

The entire request body is captured as the message payload, and if it's a valid GraphQL JSON, the query and variables are extracted into the message data.

gRPC Source

Hermod runs a gRPC server (default port 50051) that implements the SourceService.

Service Definition:

service SourceService {
  rpc Publish(PublishRequest) returns (PublishResponse);
}

You can push structured messages directly from your gRPC clients. Use the path field in the request to route to a specific Hermod gRPC source configuration.

Advanced Transformation Nodes

Beyond simple mapping and filtering, Hermod supports complex business logic within the pipeline:

  • WebAssembly (WASM): Execute logic compiled from Go, Rust, or C++ at near-native speed. Ideal for CPU-intensive transformations or proprietary algorithms.
  • Lua Scripting: Embed lightweight, flexible scripts for dynamic data manipulation without external dependencies.
  • PII Masking: Automatically discover and redact sensitive information (Credit Cards, Emails, SSNs) using a built-in regex-based scanner.
  • Stateful Aggregations: Maintain running totals, counts, or windowed averages directly in the stream.
  • Database/API Lookups: Enrich incoming messages by querying external databases or HTTP APIs in real-time.
SQL Query Builder

The DB Lookup transformation and SQL sources/sinks share an interactive SQL Query Builder in the Workflow Editor (ui/src/components/forms/SQLQueryBuilder.tsx). It is optimized for writing and editing long queries:

  • Database Explorer: Load tables, drill into columns (with types), and filter the table list. Click a table/column or the + icon to insert its name at the caret.
  • Caret-aware Quick Insert: Keyword shortcuts (SELECT, FROM, WHERE, AND, OR, JOIN, LEFT/INNER JOIN, GROUP BY, ORDER BY, HAVING, LIMIT, OFFSET, DISTINCT) and the dynamic {{.last_value}} variable are inserted at the current cursor position instead of appended at the end.
  • Format Query: A one-click formatter normalizes whitespace and breaks major clauses onto their own lines for readability.
  • Fullscreen Editor: Expand the editor into a large modal for comfortable editing of long, multi-line statements.
  • Convenience: Copy the query to the clipboard, see a live character/line counter, and run with Cmd/Ctrl + Enter.

Leases and Single-Worker Ownership

Workers acquire per-workflow leases backed by storage to ensure only one worker processes a workflow at a time. Key details:

  • Schema fields: owner_id, lease_until on workflows.
  • Worker behavior: acquire (steal if expired), renew at TTL/2, stop engine on renew failure, release on stop.
  • Metrics: hermod_lease_acquire_total, hermod_lease_steal_total, hermod_lease_renew_errors_total, and hermod_worker_leases_owned_total.
  • Readiness: /readyz includes a non-gating leases check. Make it gating with HERMOD_READY_LEASES_REQUIRED=true.

Security Headers and CORS

Production defaults are secure by default:

  • CORS allowlist via HERMOD_CORS_ALLOW_ORIGINS (comma-separated). In production, no allowlist -> no CORS.
  • Security headers: Content-Security-Policy, X-Frame-Options=DENY, Referrer-Policy=no-referrer, X-Content-Type-Options=nosniff.
  • HSTS can be forced with HERMOD_HSTS_ENABLE=true or when X-Forwarded-Proto: https is detected.
  • Worker registration requires X-Worker-Registration-Token when HERMOD_ENV=production. Provide the secret via HERMOD_WORKER_REG_TOKEN.
  • UI Build & Deployment:
    • Development: If HERMOD_ENV is not production, Hermod automatically builds the UI on startup if the ui/ source exists. This requires bun, curl, and unzip.
    • Production: Set HERMOD_ENV=production. Hermod will skip runtime builds and serve assets from the embedded filesystem. Build the UI during CI/CD using go run ./cmd/hermod --build-ui before the final go build.

Running Integration Tests

Hermod ships with env-gated integration tests.

  • Two-worker lease failover (no external deps):
    • Set HERMOD_INTEGRATION=1
    • Run: go test ./internal/engine -run TwoWorkerLeaseFailover -v -tags=integration
  • SQL sink idempotency:
    • MySQL: set HERMOD_INTEGRATION=1 and MYSQL_DSN (e.g., user:pass@tcp(host:3306)/dbname)
    • Postgres: set HERMOD_INTEGRATION=1 and POSTGRES_DSN (e.g., postgres://user:pass@host:5432/db?sslmode=disable)
    • Run: go test ./pkg/sink/mysql -tags=integration and go test ./pkg/sink/postgres -tags=integration
UI Testing

Hermod uses Vitest for unit/component testing and Cypress for End-to-End (E2E) testing.

Unit & Component Tests
cd ui
bun run test
End-to-End Tests (Cypress)
  1. Ensure the development server is running (default: http://localhost:5173).
  2. Run Cypress:
cd ui
bun run cypress:run   # Headless mode
bun run cypress:open  # Interactive mode

Continuous Integration (CI)

This repository includes a GitHub Actions workflow that:

  • Runs a quick Go build + focused tests via scripts/quick-verify.ps1 on push/PR.
  • Builds the UI (bun run build) as a separate job.

The workflow file is at .github/workflows/ci.yml. To enable optional SQL integration tests, add secrets with DSNs and create an additional job based on your environment.

Settings UI Improvements (Current)

  • Settings → Database now pre-fills from the backend via a new admin-only endpoint:
    • GET /api/config/database → returns { type, conn }. For non-SQLite DSNs, passwords are masked in the returned connection string.
  • Notification Settings page includes a "Send Test Notification" button:
    • POST /api/settings/test (admin-only) sends a test through configured channels in this order: Email → Slack → Discord → Webhook → Telegram. The UI displays per-channel results.

These endpoints require an administrator role and are used by the UI automatically. No additional configuration is needed beyond saving your settings.

Roadmap

For a detailed view of planned features and future development ideas, please refer to the ROADMAP.md file.

Contributing & Documentation

  • Always update README.md when you add a new feature or change user‑visible behavior.
    • Include a brief description, how to enable/use it, and any relevant config flags, env vars, API endpoints, or UI locations.
    • If the UI is affected, verify the UI builds (cd ui && bun run build) or run the quick verify script on Windows: pwsh -File scripts/quick-verify.ps1.
    • Keep examples up to date; add a short note under the relevant section rather than creating separate long docs.

Documentation

Index

Constants

View Source
const (
	StateStoreKey        contextKey = "stateStore"
	RegistryKey          contextKey = "registry"
	WorkflowIDKey        contextKey = "workflow_id"
	NodeIDKey            contextKey = "node_id"
	LastTraceSnapshotKey contextKey = "lastTraceSnapshot"
)

Variables

View Source
var (
	ErrNotSupported = errors.New("not supported")
)

Functions

This section is empty.

Types

type BatchSink

type BatchSink interface {
	Sink
	WriteBatch(ctx context.Context, msgs []Message) error
}

BatchSink is an optional interface for sinks that support batch writes.

type Browser

type Browser interface {
	Browse(ctx context.Context, table string, limit int) ([]Message, error)
}

Browser defines an optional interface for browsing multiple records from a table.

type ColumnDiscoverer

type ColumnDiscoverer interface {
	DiscoverColumns(ctx context.Context, table string) ([]ColumnInfo, error)
}

ColumnDiscoverer defines an optional interface for discovering columns of a table.

type ColumnInfo

type ColumnInfo struct {
	Name       string `json:"name"`
	Type       string `json:"type"`
	IsNullable bool   `json:"is_nullable"`
	IsPK       bool   `json:"is_pk"`
	IsIdentity bool   `json:"is_identity"`
	Default    string `json:"default"`
}

type Consumer

type Consumer interface {
	Consume(ctx context.Context, handler Handler) error
	Close() error
}

Consumer defines the interface for receiving messages.

type Discoverer

type Discoverer interface {
	DiscoverDatabases(ctx context.Context) ([]string, error)
	DiscoverTables(ctx context.Context) ([]string, error)
}

Discoverer defines an optional interface for discovering databases and tables.

type Formatter

type Formatter interface {
	Format(msg Message) ([]byte, error)
}

Formatter defines the interface for formatting messages before they are written to a sink.

type Handler

type Handler func(ctx context.Context, msg Message) error

Handler is a function type for processing received messages.

type IdempotencyReporter

type IdempotencyReporter interface {
	// LastWriteIdempotent returns true when the latest write was treated as a
	// duplicate (no-op), and true for conflict when a key collision with a
	// differing payload was detected (when supported by the sink).
	LastWriteIdempotent() (dedup bool, conflict bool)
}

IdempotencyReporter is an optional interface for sinks to report whether the last successful Write/WriteBatch resulted in a deduplicated (skipped) write and/or a payload conflict. Engines can use this to emit standardized metrics.

type LagReporter

type LagReporter interface {
	GetLag(ctx context.Context) (uint64, error)
}

LagReporter defines an optional interface for reporting source lag.

type Loggable

type Loggable interface {
	SetLogger(Logger)
}

Loggable defines an optional interface for things that support structured logging.

type Logger

type Logger interface {
	Debug(msg string, keysAndValues ...any)
	Info(msg string, keysAndValues ...any)
	Warn(msg string, keysAndValues ...any)
	Error(msg string, keysAndValues ...any)
}

Logger defines the interface for logging in Hermod.

type Message

type Message interface {
	ID() string
	Operation() Operation
	Table() string
	Schema() string
	Before() []byte
	After() []byte
	Payload() []byte // Primary data payload
	Metadata() map[string]string
	Data() map[string]any
	MetadataRef() map[string]string
	DataRef() map[string]any
	SetMetadata(key, value string)
	SetData(key string, value any)
	Clone() Message
	ToMap() map[string]any
	ClearPayloads()
	Retain()
	Release()
}

Message represents a generic message structure. Using interface to allow different message implementations.

type Operation

type Operation string

Operation defines the type of CDC operation.

const (
	OpCreate   Operation = "create"
	OpUpdate   Operation = "update"
	OpDelete   Operation = "delete"
	OpSnapshot Operation = "snapshot"
)

type OutboxItem

type OutboxItem struct {
	ID         string            `json:"id"`
	WorkflowID string            `json:"workflow_id"`
	SinkID     string            `json:"sink_id"`
	Payload    []byte            `json:"payload" omitzero:"true"`
	Metadata   map[string]string `json:"metadata" omitzero:"true"`
	CreatedAt  time.Time         `json:"created_at" omitzero:"true"`
	Attempts   int               `json:"attempts"`
	LastError  string            `json:"last_error,omitempty"`
	Status     string            `json:"status"` // pending, processing, failed
}

OutboxItem represents a message persisted for reliable delivery.

type OutboxStorage

type OutboxStorage interface {
	CreateOutboxItem(ctx context.Context, item OutboxItem) error
	ListOutboxItems(ctx context.Context, status string, limit int) ([]OutboxItem, error)
	DeleteOutboxItem(ctx context.Context, id string) error
	UpdateOutboxItem(ctx context.Context, item OutboxItem) error
}

OutboxStorage defines the interface for persisting and retrieving outbox items.

type PendingWorkReporter

type PendingWorkReporter interface {
	// PendingWork reports whether the source has delivered messages that have
	// not been acknowledged yet. known is false when the source cannot tell,
	// so callers fall back to their own signals rather than concluding that
	// nothing is outstanding — a distinction wrappers need, since a decorator
	// implements this interface on behalf of sources that may not.
	PendingWork() (pending bool, known bool)
}

PendingWorkReporter is an optional interface for sources that can say precisely whether they are still owed acknowledgements.

It exists because replication lag does not answer that question. Lag measures the distance between the server's current WAL position and the position this pipeline has confirmed, and the server's position advances on every write anywhere on that instance — including databases and tables this workflow does not follow. An idle workflow attached to a busy server therefore reports lag that grows without limit and never returns to zero, which is indistinguishable from a wedge if lag is all you look at. What distinguishes them is whether the source actually handed over messages that were never acknowledged.

type Producer

type Producer interface {
	Produce(ctx context.Context, msg Message) error
	Close() error
}

Producer defines the interface for sending messages.

type PublicationInfo

type PublicationInfo struct {
	Name      string   `json:"name"`
	AllTables bool     `json:"all_tables"`
	Tables    []string `json:"tables"`
}

PublicationInfo describes an existing publication and the tables it covers.

type ReadyChecker

type ReadyChecker interface {
	IsReady(ctx context.Context) error
}

ReadyChecker is an optional interface for sources that support deep readiness checks.

type ReplicationDiscoverer

type ReplicationDiscoverer interface {
	DiscoverReplicationSlots(ctx context.Context) ([]ReplicationSlotInfo, error)
	DiscoverPublications(ctx context.Context) ([]PublicationInfo, error)
}

ReplicationDiscoverer is an optional interface for CDC sources (e.g. PostgreSQL) that can enumerate existing logical replication slots and publications so the user can choose an existing one or decide to create a new one.

type ReplicationSlotInfo

type ReplicationSlotInfo struct {
	Name     string `json:"name"`
	Plugin   string `json:"plugin"`
	SlotType string `json:"slot_type"`
	Database string `json:"database"`
	Active   bool   `json:"active"`
}

ReplicationSlotInfo describes an existing logical replication slot.

type SQLExecutor

type SQLExecutor interface {
	ExecuteSQL(ctx context.Context, query string) ([]map[string]any, error)
}

SQLExecutor is an interface for sources and sinks that can execute arbitrary SQL queries.

type Sampler

type Sampler interface {
	Sample(ctx context.Context, table string) (Message, error)
}

Sampler defines an optional interface for fetching a sample record from a table.

type Sink

type Sink interface {
	Write(ctx context.Context, msg Message) error
	Ping(ctx context.Context) error
	Close() error
}

type SinkOperationMode

type SinkOperationMode string

SinkOperationMode defines how a sink should treat incoming messages.

const (
	SinkOpModeAuto   SinkOperationMode = "auto"   // Follow message operation
	SinkOpModeInsert SinkOperationMode = "insert" // Always insert
	SinkOpModeUpsert SinkOperationMode = "upsert" // Always upsert/merge
	SinkOpModeUpdate SinkOperationMode = "update" // Always update
	SinkOpModeDelete SinkOperationMode = "delete" // Always delete
)

type Snapshottable

type Snapshottable interface {
	Snapshot(ctx context.Context, tables ...string) error
}

Snapshottable defines an optional interface for sources that support manual snapshots.

type Source

type Source interface {
	Read(ctx context.Context) (Message, error)
	Ack(ctx context.Context, msg Message) error
	Ping(ctx context.Context) error
	Close() error
}

Source defines the interface for reading data from a CDC source.

type StateStore

type StateStore interface {
	Get(ctx context.Context, key string) ([]byte, error)
	Set(ctx context.Context, key string, value []byte) error
	Delete(ctx context.Context, key string) error
}

StateStore defines an interface for persistent key-value storage used by transformers.

type Stateful

type Stateful interface {
	GetState() map[string]string
	SetState(state map[string]string)
}

Stateful defines an optional interface for sources and sinks that have persistent state.

type StreamLivenessReporter

type StreamLivenessReporter interface {
	// LastStreamActivity reports when anything was last received on the
	// stream, keepalives included. A zero time means the stream has not
	// started, which is not a fault.
	LastStreamActivity() time.Time
	// StreamSilenceThreshold is how long silence must last before it is a
	// fault. Zero disables the check — appropriate when the server is
	// configured not to send keepalives at all (wal_sender_timeout = 0).
	StreamSilenceThreshold() time.Duration
}

StreamLivenessReporter is an optional interface for sources that hold a long-lived, server-pushed stream open — a logical replication connection above all.

Such a stream is expected to deliver something at a known cadence even when there are no changes to send: PostgreSQL emits a keepalive on an idle replication connection every wal_sender_timeout/2. That makes silence measurable evidence rather than an absence of evidence, and it is the only signal that distinguishes a source which has stopped delivering from one which simply has nothing to deliver. Every other indicator the engine has — buffer depth, sink queues, processed counts — sees only work the source has already handed over, so a stream wedged upstream of them is invisible.

type StringMap

type StringMap map[string]string

StringMap is a map[string]string that tolerates JSON values of other scalar types when unmarshalling. UI clients sometimes send configuration values as booleans or numbers (e.g. {"use_cdc": true, "batch_size": 100}) rather than strings. Decoding such payloads directly into map[string]string fails with errors like "json: cannot unmarshal bool into Go struct field ... of type string". StringMap coerces those scalar values to their string form so the API stays robust regardless of how the client encodes them.

func (*StringMap) UnmarshalJSON

func (m *StringMap) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes a JSON object into a StringMap, coercing scalar values (string, bool, number) to strings. Null becomes an absent/empty map and null values become empty strings. Objects and arrays are preserved as their JSON encoding.

type TraceRecorder

type TraceRecorder interface {
	RecordStep(ctx context.Context, workflowID, messageID string, step TraceStep)
}

TraceRecorder defines the interface for recording message traces.

type TraceStep

type TraceStep struct {
	NodeID    string         `json:"node_id"`
	Timestamp time.Time      `json:"timestamp" omitzero:"true"`
	Duration  time.Duration  `json:"duration" omitzero:"true"`
	Before    map[string]any `json:"before,omitempty" omitzero:"true"`
	After     map[string]any `json:"after,omitempty" omitzero:"true"`
	Error     string         `json:"error,omitempty"`
	Lineage   string         `json:"lineage,omitempty"`
}

TraceStep represents a single step in a message's journey.

type Transactional

type Transactional interface {
	Begin(ctx context.Context) error
	Commit(ctx context.Context) error
	Rollback(ctx context.Context) error
}

Transactional is an optional interface for sources and sinks that support atomic transactions.

type TwoPhaseCommit

type TwoPhaseCommit interface {
	Transactional
	// Prepare votes to commit, under a transaction ID the coordinator supplies.
	//
	// The ID is an argument rather than a return value because of what happens
	// when the process dies mid-flight. A participant that named its own
	// transaction left a window between Prepare returning and the coordinator
	// writing that name down: a crash there leaves a prepared transaction
	// nothing can name, and on PostgreSQL a prepared transaction holds its
	// locks cluster-wide until somebody finds it by hand in
	// pg_prepared_xacts. Supplying the ID lets the coordinator record it
	// *before* the transaction exists, so recovery can always name what it
	// might have created.
	//
	// The returned string is the ID actually in force. It is normally the one
	// passed in; a participant that cannot honour 2PC in its current
	// configuration may return its own sentinel instead — the PostgreSQL sink
	// does this behind a transaction pooler, where PREPARE TRANSACTION is
	// unavailable and it degrades to a local commit.
	Prepare(ctx context.Context, txID string) (string, error)
	CommitPrepared(ctx context.Context, txID string) error
	RollbackPrepared(ctx context.Context, txID string) error
}

TwoPhaseCommit is an optional interface for sinks that support 2PC.

It is driven by pkg/engine/twopc.Coordinator, which a transactional sink group (pkg/comm/sink/txgroup) uses to commit several sinks atomically. The coordinator makes its decision durable before telling any participant, so a crash mid-round is resolved on the next start rather than left in doubt.

Implement this only with genuine prepared-transaction semantics. A no-op implementation is actively harmful: the contract reports failure through the error return, so a Rollback that returns nil without rolling anything back tells a future coordinator the abort succeeded while the writes stand.

type TwoPhaseCommitPreflight

type TwoPhaseCommitPreflight interface {
	// PreflightTwoPhaseCommit returns nil only if a later Prepare would give a
	// genuinely in-doubt, resolvable transaction.
	PreflightTwoPhaseCommit(ctx context.Context) error
}

TwoPhaseCommitPreflight is an optional interface for participants that can check, before any data moves, whether real two-phase commit is actually available to them.

It exists because "implements TwoPhaseCommit" is not the same as "can honour it right now". A PostgreSQL sink behind a transaction pooler cannot use PREPARE TRANSACTION at all — the in-doubt transaction has to be resolved on the backend that created it, which a pooler cannot guarantee — and PostgreSQL ships with max_prepared_transactions = 0, which makes PREPARE fail outright.

Without this check those cases surface as a mid-batch failure at best, and at worst as a participant that quietly commits when asked to prepare: the coordinator then believes it can still roll back, and the transaction silently diverges. A participant that cannot guarantee the contract should say so here and be refused at start-up.

type ValidatingSink

type ValidatingSink interface {
	Sink
	Validate(ctx context.Context, msg Message) error
}

ValidatingSink is an optional interface for sinks that support pre-write validation.

Directories

Path Synopsis
cmd
hermod command
hermod-edge command
hermodctl command
examples
eventstore command
internal
ai
api
engine/registry/nodes
Package nodes is the one import that makes every workflow node type available.
Package nodes is the one import that makes every workflow node type available.
runtimetune
Package runtimetune applies process-wide runtime tuning (soft memory limit and GC behaviour) so Hermod stays within its lightweight, low-memory target and returns unused memory to the operating system promptly.
Package runtimetune applies process-wide runtime tuning (soft memory limit and GC behaviour) so Hermod stays within its lightweight, low-memory target and returns unused memory to the operating system promptly.
sse
storage/configsecrets
Package configsecrets decides which connector configuration values are credentials, and encrypts them at rest.
Package configsecrets decides which connector configuration values are credentials, and encrypts them at rest.
pkg
comm/conformance
Package conformance provides a shared contract suite that every Hermod source and sink must satisfy.
Package conformance provides a shared contract suite that every Hermod source and sink must satisfy.
comm/sink/txgroup
Package txgroup writes to several sinks inside one distributed transaction.
Package txgroup writes to several sinks inside one distributed transaction.
engine/twopc
Package twopc coordinates two-phase commit across sinks.
Package twopc coordinates two-phase commit across sinks.
infra/pgxutil
Package pgxutil centralizes pgx connection configuration so that every Postgres connection (source metadata, sink pool, pgvector pool, state store) is built consistently and is safe to use behind a transaction/statement pooling proxy such as PgBouncer.
Package pgxutil centralizes pgx connection configuration so that every Postgres connection (source metadata, sink pool, pgvector pool, state store) is built consistently and is safe to use behind a transaction/statement pooling proxy such as PgBouncer.
infra/sqlident
Package sqlident validates and quotes SQL identifiers.
Package sqlident validates and quotes SQL identifiers.
infra/tracing
Package tracing carries trace context on a message rather than in a Go context.
Package tracing carries trace context on a message rather than in a Go context.
scripts
atomic_wipe command
drop_all_slots command
drop_slot command
flow command
insert command
lsn command
pub command
replica command
seed command
setup_test_db command
slots command
ui

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL